Compare commits

...

2 Commits

Author SHA1 Message Date
5b9216e764 Apk compilation 2026-06-18 14:09:54 +02:00
9a23863320 TTs whisper 2026-06-18 13:45:32 +02:00
98 changed files with 14644 additions and 514 deletions

View File

@@ -44,20 +44,28 @@ jobs:
working-directory: ./apk/android
run: |
./gradlew assembleRelease
- name: 📦 Zip APK
working-directory: .
run: |
mkdir -p dist
cp apk/android/app/build/outputs/apk/release/app-release.apk dist/
zip -j dist/build.zip dist/app-release.apk
- name: 📤 Upload APK Artifact
uses: actions/upload-artifact@v3
with:
name: app-release-apk
path: apk/android/app/build/outputs/apk/release/app-release.apk
release:
runs-on: docker
needs: [build]
steps:
- name: Download Web Artifact
uses: actions/download-artifact@v3
with:
name: app-release-apk
path: dist
- name: Create Release
uses: https://gitea.com/actions/gitea-release-action@v1
working-directory: dist
with:
tag_name: latest
name: Latest Build
overwrite_files: true
files: |
- build.zip
dist/app-release-apk.zip
env:
GITEA_TOKEN: ${{ secrets.GITEA }}

View File

@@ -74,8 +74,6 @@ jobs:
name: backend
path: backend/backend.zip
release:
runs-on: docker
needs: [build-web, build-backend]

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

345
AGENTS.md Normal file
View File

@@ -0,0 +1,345 @@
# QuiBot Project — Agent Guide
## Overview
QuiBot is an educational robotics platform (UPC/UNE collaboration) consisting of a programmable robot with color-block recognition, gesture control, stepper motors, RGB LED eyes, and multiple input methods (web dashboard, Android voice app). The codebase comprises four independent application layers communicating via HTTP JSON APIs.
```
[quibot-web Nuxt SPA] ──HTTP──> [backend Express] ──HTTP──> [raspi FastAPI (Pi)]
│ │
▼ ▼
[apk Expo RN app] ──HTTP──> (same backend) [Python hardware drivers]
```
**Tech stack**: Python (pigpio, FastAPI) | TypeScript/Express | Nuxt 4/Vue 3 | Expo/React Native
**No database**: All state is in-memory, file-based (`/tmp/quibot-audio/`), or localStorage.
---
## Directory Structure
```
quibot/
├── raspi/ # Raspberry Pi brain — Python, controls hardware
│ ├── main.py # FastAPI server (port 8000) on the Pi
│ ├── quibot.py # Main program: block/gesture threads (like Arduino QuiBot.ino)
│ ├── motion.py # Stepper class, homing, line-following, high-level tasks
│ ├── gesture.py # PAJ7620U2 gesture sensor (I2C, polled at 50ms)
│ ├── blocks.py # TCS34725 color sensor + servo block ejection
│ ├── eyes.py # WS2811 LED matrix (128 LEDs, pigpio waveforms, breathing animation)
│ ├── pins.py # BCM GPIO pin map for all hardware
│ └── tests/ # Manual diagnostic scripts (not automated)
├── backend/ # Local Express server (port 3000), proxy to Pi
│ ├── src/
│ │ ├── index.ts # Express entry: CORS, JSON parser, /health
│ │ ├── config.ts # Env config: RASPBERRY_PI_HOST, PORT, QUIBOT_TOKEN
│ │ ├── routes/router.ts # Mounts all controllers
│ │ ├── services/raspi.service.ts # Axios proxy layer to Pi FastAPI
│ │ └── controllers/
│ │ ├── motor.controller.ts # Motor step/stop/upload
│ │ ├── audio.controller.ts # Audio file lifecycle (incoming/locked/processed)
│ │ ├── command.controller.ts # POST /commands proxy to raspi /run
│ │ └── settings.controller.ts # GET/PUT /settings runtime config
│ └── dist/ # Compiled output (generated)
├── quibot-web/ # Nuxt 4 dashboard SPA
│ ├── app/app.vue # Single-page control panel: block queue, D-pad, eye controls, gesture log
│ ├── server/api/ # Nitro server routes proxying to raspi
│ │ ├── motor/step/[direction].post.ts
│ │ └── motor/stop.post.ts
│ ├── nuxt.config.ts # Runtime config: QUIBOT_BASE_URL, QUIBOT_TOKEN
│ └── .output/ # Built Nitro output
├── apk/ # Expo React Native voice recorder ("VoiceDrop")
│ ├── app/index.tsx # Recording screen + upload
│ ├── app/settings.tsx # Backend URL/token configuration (AsyncStorage)
│ └── lib/recorder-settings.ts # AsyncStorage wrapper
├── .gitea/workflows/ # CI/CD pipelines
│ ├── build.yml # Web + backend → zip artifacts + Gitea release
│ └── build-apk.yml # Expo prebuild + signed APK → Gitea release
├── build.sh # Placeholder
└── README.md # Project overview (Catalan)
```
---
## Raspberry Pi Layer (`raspi/`)
**Hardware target**: Raspberry Pi Zero 2W controlling a robot with:
- 5 NEMA-style stepper motors (wheels x2, arms x2, syringe) via A4988/TB600 drivers in STEP/DIR mode
- VL53L0X ToF distance sensor (I2C bus 3)
- PAJ7620U2 gesture sensor (I2C bus 3, polled at 50ms)
- TCS34725 color sensor (bit-banged I2C on GPIO22/27)
- ADS1115 ADC for TCRT5000 line-following IR sensors
- WS2811 RGB LED matrix (2x 8x8 = 128 LEDs, GPIO26, pigpio waveforms at -s 1)
- Servo motor (GPIO10 PWM) for block ejection
- Hall-effect endstops on GPIOs 12, 16, 17
- Optional: I2S audio amp (MAX98357A) + mic (SPH0645)
### Key files
- **`pins.py`** — BCM GPIO pin numbering for every component (STEP, DIR, EN pins, I2C lines, endstops, LED_DATA on GPIO26)
- **`motion.py`** — `Stepper` class with AccelStepper-style acceleration profiling via `pigpio.gpio_trigger()`. 5 motor instances (`wheel_R`, `wheel_L`, `arm_R`, `arm_L`, `syringe`). Continuous stepper daemon thread (`_stepper_loop`) at ~100Hz. Homing routines read Hall-effect endstops. Line-following with proportional correction on TCRT5000 values via ADS1115.
- **`gesture.py`** — Raw I2C via `smbus2` (bus 3). Two-register-bank init (~240 total register writes). Polls gesture result registers 0x43/0x44 every 50ms. Returns: GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT, GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE
- **`blocks.py`** — TCS34725 RGB reads classified via Manhattan distance against calibrated reference table (BK/RD/GN/BU/YE/OG/VT). Smooth servo movement with 3us micro-steps.
- **`eyes.py`** — 128 WS2811 LEDs via pigpio waveforms at 1us resolution. Pre-defined shapes: EYES_OPEN, EYES_FW, EYES_DOWN, EYES_GESTURE. Breathing thread oscillates brightness 80-170 at 50ms intervals.
- **`quibot.py`** — Main program (equivalent to original Arduino QuiBot.ino). Two threading tasks via FreeRTOS-style pattern: `task_read_blocks()` (color→action mapping) and `task_read_gestures()` (WAVE toggles between block/gesture mode). `threading.Lock` prevents concurrent motor movements. Handles SIGINT/SIGTERM for graceful shutdown.
- **`main.py`** — FastAPI server on port 8000 with CORS. File-based state management using `/tmp/quibot-audio/` (incoming/locked/processed directories). Token auth via query parameter matching `QUIBOT_TOKEN`.
### Color→Action Mapping (in quibot.py)
| Color | Action |
|-------|--------|
| RED | Advance forward |
| GREEN | Turn right |
| BLUE | Turn left |
| YELLOW | Take/block pick-up |
| ORANGE | Leave/eject |
| VIOLET | Idle |
| BLACK | Reference / no block |
### Motor Position Tracking
`Stepper` class in `motion.py` tracks absolute position in steps via `_pos` and `_target`. Endstops provide physical reference during homing. The stepper loop evaluates acceleration profiles to generate STEP pulses at correct intervals.
---
## Backend Layer (`backend/`)
**Role**: Express.js HTTP proxy sitting between frontend/mobile and the Raspberry Pi's FastAPI server. Token passthrough, no business logic.
### Configuration (`.env`, loaded by `config.ts`)
| Variable | Default | Purpose |
|----------|---------|---------|
| `RASPBERRY_PI_HOST` | `http://raspberrypi.local` | Pi API URL |
| `RASPBERRY_PI_PORT` | `8000` | Pi API port |
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token for all Pi endpoints |
| `PORT` | `3000` | Backend listen port |
### Architecture
```
index.ts → Express app, CORS, JSON parser, /health endpoint
routes/router.ts → Mounts all controllers under /motor, /audio, /commands, /settings
config.ts → Mutable getter/setter env vars (runtime update via PUT /settings)
raspi.service.ts → Axios proxy methods for every Pi endpoint + multipart file upload handling
```
### Controllers
- **`motor.controller.ts`** — `POST /motor/step/forward`, `/motor/step/backward`, `/motor/stop`. Also `POST /motor/upload` (multer multipart → proxied as FormData to Pi).
- **`audio.controller.ts`** — `GET /audio/incoming`, `POST /audio/lock/:filename`, `/unlock/:filename`, `/cancel/:filename`, `/process/:filename`. All proxy to raspi audio file lifecycle endpoints.
- **`command.controller.ts`** — `POST /commands { task }` → proxied to raspi `/run?task=...&token=...`
- **`settings.controller.ts`** — `GET /settings` returns config; `PUT /settings` updates `raspberryPi.host`, `raspberryPi.port`, `token` at runtime.
### Build/Run
```bash
cd backend
npm install # Installs express, axios, multer, dotenv, cors, typescript
npx tsc # Compiles to dist/
node dist/index.js # Or use tsx/nodemon for dev
```
---
## Web Frontend (`quibot-web/`)
**Stack**: Nuxt 4 (Nitro) + Vue 3. Single-page dashboard SPA.
### Runtime config (`nuxt.config.ts`)
| Key | Default | Purpose |
|-----|---------|---------|
| `QUIBOT_BASE_URL` | `http://quibot:8000` | Base URL for raspi FastAPI |
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token |
### UI Panels (`app/app.vue` — single-file SPA, 1369 lines)
1. **Block Queue Panel** — Displays color blocks in a queue (localStorage persistence + demo fallback). Shows action descriptions per color.
2. **Motion Controls** — D-pad grid: up=forward, down=back, left/right=turns, center=stop. Sends `$fetch('/api/motor/step/forward')` etc.
3. **Eye Controls** — Shape selector (open/forward/down/gesture), 8-color picker. Calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`.
4. **Gesture Sensor Panel** — Toggle between Block Mode / Gesture Mode. Gesture detection history log. Reference table of all 8 gestures.
### State & Styling
- Dark/light theme via CSS custom properties, persisted in localStorage.
- Block queue data stored in localStorage with demo fallback.
- Toast notifications for success/error feedback.
- Responsive layout with CSS Grid (mobile-adaptive).
### Server Routes (`server/api/`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/motor/stop` | Proxies to raspi `/motor/stop` |
| POST | `/api/motor/step/:direction` | Proxies to raspi `/motor/step/forward\|backwards` |
**Note**: The frontend also calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`, `/api/gesture/on`, `/api/gesture/off` — server routes for these may need to be created (frontend references them but they don't have explicit server handlers yet).
### Build/Run
```bash
cd quibot-web
npm install
npx nuxt build # Produces .output/
# Dev mode:
npx nuxt dev
```
CI builds the Nuxt output and zips it for distribution.
---
## Android APK (`apk/`)
**Stack**: Expo SDK ~54 + React Native 0.81.5 + Expo Router ~6.0. App name: "VoiceDrop".
### Features
- Records audio via `expo-av` (`Audio.Recording` — produces .m4a)
- Auto-uploads on stop as multipart form data to configurable backend URL
- Displays recording timer, status messages, raw server response (first 400 chars)
### Screens
- **`app/index.tsx`** — Recorder screen: start/stop recording, upload, status
- **`app/settings.tsx`** — Backend URL, Bearer token, form field name. Saved to AsyncStorage under `recorder.*` namespace.
### Persistence
Settings stored in `@react-native-async-storage/async-storage` (keys: `recorder.backendUrl`, `recorder.authToken`, `recorder.fieldName`).
### Build/Run
```bash
cd apk
npm install
npx expo start # Dev mode
# Production APK (local):
./build.sh # expo prebuild → generates android/ → gradle assembleRelease
```
CI: `build-apk.yml` runs expo prebuild, decodes keystore from secrets, builds signed release APK.
---
## Complete API Reference
### Raspberry Pi FastAPI (`raspi/main.py`) — port 8000
| Method | Path | Params/Body | Description |
|--------|------|-------------|-------------|
| POST | `/run` | query: `task`, `token` | Runs whitelisted system commands (restart_nginx, uptime, update) |
| POST | `/motor/step/forward` | query: `token` | Starts motor forward (daemon thread) |
| POST | `/motor/step/backwards` | query: `token` | Starts motor backward (daemon thread) |
| POST | `/motor/stop` | query: `token` | Disables motor driver (GPIO EN HIGH) |
| POST | `/audio/upload` | multipart: `file`, query: `format` | Saves to `/tmp/quibot-audio/incoming/`, returns filename + lock_url |
| GET | `/audio/incoming` | — | Lists files with size and modified time |
| POST | `/audio/lock/{filename}` | — | incoming → locked (claim for processing) |
| POST | `/audio/unlock/{filename}` | — | locked → incoming (release) |
| POST | `/audio/cancel/{filename}` | — | locked → incoming (cancel) |
| POST | `/audio/process/{filename}` | — | locked → processed |
**Auth**: Query parameter `token` matching `QUIBOT_TOKEN` env var (default: `MY_SECRET_TOKEN`).
### Backend Express (`backend/`) — port 3000
| Method | Path | Body | Description |
|--------|------|------|-------------|
| GET | `/health` | — | Returns settings object |
| POST | `/commands` | `{ task }` | Proxy to raspi `/run` |
| POST | `/motor/step/forward` | — | Motor forward proxy |
| POST | `/motor/step/backward` | — | Motor backward proxy (maps to raspi `/backwards`) |
| POST | `/motor/stop` | — | Motor stop proxy |
| POST | `/motor/upload` | multipart file | Audio upload via multer in-memory buffer |
| GET | `/audio/incoming` | — | List incoming audio files |
| POST | `/audio/lock/:filename` | — | Lock audio file |
| POST | `/audio/unlock/:filename` | — | Unlock audio file |
| POST | `/audio/cancel/:filename` | — | Cancel locked audio |
| POST | `/audio/process/:filename` | — | Mark processed |
| GET | `/settings` | — | Returns config |
| PUT | `/settings` | `{ raspberryPi: { host, port }, token }` | Update runtime config |
---
## Command Flow Examples
### Web → Motion Control
```
User clicks "Forward" in D-pad
→ $fetch('/api/motor/step/forward', { method: 'POST' })
→ Nuxt Nitro route: server/api/motor/step/[direction].post.ts
→ $fetch(config.quibotBaseUrl + '/motor/step/forward', { query: { token } })
→ raspi FastAPI /motor/step/forward
→ motor_step("forward") in daemon thread
→ step_motor(200, DIR, 1ms pulses)
```
### Block Processing (internal to Pi)
```
Child inserts colored block → quibot.py task_read_blocks() polls distance sensor
→ When detected <80mm: read_block_color() via TCS34725
→ Manhattan distance classification against color lookup table
→ RED: eyes_turn_on(EYES_FW, DARK_RED, 2)
_execute_action(task_move_to, CROSSING)
→ enable_wheels(ON) → follow_line_loop(speed) (proportional on TCRT5000)
→ After action: servo_move_to(EJECT_POSITION)
```
### APK → Audio Upload
```
User stops recording in VoiceDrop → expo-av .m4a file
→ POST {backendUrl} with FormData {fieldName: "file"} + Bearer auth
→ raspi FastAPI saves to /tmp/quibot-audio/incoming/{uuid}.wav
→ Returns: { status: "received", filename, lock_url }
```
### Gesture Mode Toggle
```
User toggles mode in web UI
→ _execute_action() locks mutex
→ If gesture mode: eyes_gesture_mode_on() (double cyan flash on 128-LED matrix)
→ Eyes breathing thread at MAX_BR(170) brightness
```
---
## CI/CD (`/.gitea/workflows/`)
### `build.yml` — Web + Backend
- Triggers: Push to `master`
- Builds web: `npm install && npx nuxt build`, zips `.output/`
- Builds backend: Zips entire `backend/` directory
- Creates Gitea release "latest" with both zip artifacts
### `build-apk.yml` — Mobile
- Triggers: Push to `master`
- expo prebuild → decode keystore from secrets → `./gradlew assembleRelease`
- Creates Gitea release with APK artifact
---
## Testing
All tests in `raspi/tests/` are **manual diagnostic scripts** (not automated frameworks). Each test is run independently by uncommenting the desired function call at the bottom of the file. Requirements:
- `sudo pigpiod -s 1` daemon running
- Python venv activated with hardware dependencies installed
- Pi connected to robot hardware
| Test | What it verifies |
|------|-----------------|
| `test_simple_motor.py` | Low-level motor driver via direct GPIO writes |
| `test_motion.py` | Wheel steppers, arm/syringe homing, VL53L0X distance, ADS1115 line sensors |
| `test_blocks.py` | Servo sweep (open/eject/open), color sensor readings, raw RGB calibration |
| `test_gesture.py` | PAJ7620U2 I2C connection + 30-second gesture capture |
| `test_eyes.py` | LED shape/color rendering, animation repeat/direction, gesture animations, breathing |
---
## Key Conventions
1. **Token auth** is always a query parameter matching `QUIBOT_TOKEN` (default: `MY_SECRET_TOKEN`)
2. **No database** — use filesystem for persistence, localStorage for web client state
3. **Backend is a dumb proxy** — no business logic, just forwards HTTP requests with token passthrough
4. **Motor commands are fire-and-forget** — motor runs in daemon thread until `/motor/stop`
5. **Audio lifecycle**: incoming → locked (claim) → processed OR unlocked (release) / cancelled
6. **Eyes breathing** runs continuously at MIN_BR(80)-MAX_BR(170) brightness in background
7. **`quibot.py` owns block/gesture autonomy** — blocks are processed internally on the Pi without backend/web involvement
8. **All paths use forward slashes** in URLs; kebab-case for params

View File

@@ -5,79 +5,18 @@ Normes del repositori:
- Sha de treballar en branques pròpies (opcional)
- No es pot modificar la carpeta daltres zones
# Estructura del projecte
# Nuxt Minimal Starter
- robot: Carpeta amb totes les instruccions necessaries per poder reconstruir el robot
- raspi: Carpeta amb el codi necessari que cal carregar dins de la raspberry pi
- backend: Backend de tot el controlador del robot. S'executa en un portatil en local
- quibot-web: Frontend web que es comunica amb el backend
- apk: Aplicació Android que també es comunica amb el backend.
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
# Instalació
## Setup
Necesitem primer una raspberry operativa. Un cop operativa cal descarregar el codi que controla el robot. Es necesari tenir python.
Make sure to install dependencies:
- Cal passar la carpeta backend a la raspberry pi
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

BIN
UPCLogo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -32,6 +32,7 @@
},
"plugins": [
"expo-router",
"@react-native-voice/voice",
[
"expo-av",
{

View File

@@ -1,10 +1,12 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
import Voice from "@react-native-voice/voice";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Svg, { Path } from "react-native-svg";
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
NativeModules,
Platform,
Pressable,
ScrollView,
@@ -12,8 +14,8 @@ import {
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { loadRecorderSettings } from "@/lib/recorder-settings";
import { getStrings, type Locale, t } from "@/lib/translations";
function formatDuration(durationMs: number) {
const totalSeconds = Math.floor(durationMs / 1000);
@@ -25,39 +27,22 @@ function formatDuration(durationMs: number) {
.padStart(2, "0")}`;
}
function buildMimeType(uri: string) {
const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase();
switch (extension) {
case "wav":
return "audio/wav";
case "caf":
return "audio/x-caf";
case "webm":
return "audio/webm";
case "mp3":
return "audio/mpeg";
default:
return "audio/m4a";
}
}
function buildFileExtension(uri: string) {
return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a";
}
export default function RecorderScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
const [recording, setRecording] = useState<Audio.Recording | null>(null);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
const [recordingMs, setRecordingMs] = useState(0);
const [statusMessage, setStatusMessage] = useState(
"Ready to record and send audio.",
);
const [locale, setLocale] = useState<Locale>("ca");
const [strings, setStrings] = useState(() => getStrings("ca"));
const [transcript, setTranscript] = useState("");
const [interimTranscript, setInterimTranscript] = useState("");
const [statusMessage, setStatusMessage] = useState("");
const [responsePreview, setResponsePreview] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isHolding, setIsHolding] = useState(false);
const [listeningMs, setListeningMs] = useState(0);
const startRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const listeningActiveRef = useRef(false);
const refreshSettings = useCallback(() => {
let isMounted = true;
@@ -72,10 +57,11 @@ export default function RecorderScreen() {
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLocale(settings.language);
setStrings(getStrings(settings.language));
} catch {
if (isMounted) {
setStatusMessage("Could not load saved backend settings.");
setStatusMessage(strings.loadError);
}
}
}
@@ -90,169 +76,218 @@ export default function RecorderScreen() {
useFocusEffect(refreshSettings);
useEffect(() => {
if (!recording) {
if (Platform.OS === "web" || !NativeModules.Voice) {
return;
}
const interval = setInterval(() => {
void recording.getStatusAsync().then((status) => {
if (typeof status.durationMillis === "number") {
setRecordingMs(status.durationMillis ?? 0);
}
});
Voice.onSpeechStart = (e) => {
console.log("Voice.onSpeechStart", e);
setTranscript("");
setInterimTranscript("");
setStatusMessage(strings.recording);
startRef.current = Date.now();
timerRef.current = setInterval(() => {
const elapsed = Date.now() - startRef.current;
setListeningMs(elapsed);
}, 250);
};
Voice.onSpeechEnd = (e) => {
console.log("Voice.onSpeechEnd", e);
listeningActiveRef.current = false;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
Voice.onSpeechResults = (e) => {
console.log("Voice.onSpeechResults", e);
const values = (e as unknown as { value: string[] }).value;
if (values && values.length > 0) {
const last = values[values.length - 1];
setTranscript(last);
}
};
Voice.onSpeechPartialResults = (e) => {
console.log("Voice.onSpeechPartialResults", e);
const values = (e as unknown as { value: string[] }).value;
if (values && values.length > 0) {
const last = values[values.length - 1];
setInterimTranscript(last || "");
}
};
Voice.onSpeechError = (e) => {
console.log("Voice.onSpeechError", e);
const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error";
setStatusMessage(message);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setListeningMs(0);
listeningActiveRef.current = false;
Alert.alert(strings.recordingFailedTitle, message);
};
return () => {
clearInterval(interval);
Voice.removeAllListeners();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [recording]);
}, []);
useEffect(() => {
return () => {
if (recording) {
void recording.stopAndUnloadAsync().catch(() => undefined);
if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) {
Voice.stop().catch(() => undefined);
listeningActiveRef.current = false;
}
};
}, [recording]);
}, []);
async function startRecording() {
async function startListening() {
try {
setResponsePreview("");
setRecordingUri(null);
setTranscript("");
setInterimTranscript("");
const localeCode =
locale.includes("ca")
? "ca-ES"
: locale.includes("es")
? "es-ES"
: "en-US";
setIsHolding(true);
if (Platform.OS === "web") {
console.log("Voice not available on web");
Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS.");
setIsHolding(false);
return;
}
if (!NativeModules.Voice) {
Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules.");
setIsHolding(false);
return;
}
await Voice.start(localeCode);
listeningActiveRef.current = true;
} catch (error) {
const msg = error instanceof Error ? error.message : "Failed to start speech recognition";
console.error("Voice.start failed:", error);
Alert.alert(strings.recordingFailedTitle, msg);
}
}
const permission = await Audio.requestPermissionsAsync();
async function stopListeningAndSend() {
setIsHolding(false);
const wasListening = listeningActiveRef.current;
if (!wasListening) {
setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord);
return;
}
listeningActiveRef.current = false;
if (Platform.OS === "web") {
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
if (finalText) {
await sendCommand(finalText);
} else {
setStatusMessage(strings.readyToRecord);
}
return;
}
try {
await Voice.stop();
if (!permission.granted) {
setStatusMessage("Microphone permission was denied.");
Alert.alert(
"Microphone access required",
"Enable microphone access to record audio.",
);
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
if (!finalText) {
setStatusMessage(strings.readyToRecord);
return;
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
playsInSilentModeIOS: true,
shouldDuckAndroid: true,
staysActiveInBackground: false,
});
const result = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
setRecording(result.recording);
setRecordingMs(0);
setStatusMessage("Recording in progress.");
await sendCommand(finalText);
} catch (error) {
setStatusMessage("Recording could not be started.");
Alert.alert(
"Recording failed",
error instanceof Error ? error.message : "Unknown recording error.",
);
listeningActiveRef.current = false;
const msg = error instanceof Error ? error.message : "Stop failed";
console.error("Voice.stop failed:", error);
setStatusMessage(msg);
}
}
async function stopRecording() {
if (!recording) {
async function sendCommand(text: string) {
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
const commandUrl = trimmedUrl.endsWith("/commands")
? `${trimmedUrl}/text`
: `${trimmedUrl}/commands/text`;
if (!commandUrl) {
setStatusMessage(strings.noBackendUrl);
return;
}
try {
const activeRecording = recording;
const currentStatus = await activeRecording.getStatusAsync();
setIsSending(true);
setStatusMessage(strings.uploadingRecording);
await activeRecording.stopAndUnloadAsync();
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = activeRecording.getURI();
setRecording(null);
setRecordingMs(currentStatus.durationMillis ?? recordingMs);
setRecordingUri(uri);
setStatusMessage(
uri ? "Recording finished. Preparing to send voice message." : "Recording finished.",
);
if (uri && backendUrl.trim()) {
await uploadRecording(uri);
}
} catch (error) {
setStatusMessage("Recording could not be stopped cleanly.");
Alert.alert(
"Stop failed",
error instanceof Error ? error.message : "Unknown stop error.",
);
}
}
async function uploadRecording(uriOverride?: string) {
const targetUri = uriOverride ?? recordingUri;
if (!targetUri) {
return;
}
const trimmedUrl = backendUrl.trim();
if (!trimmedUrl) {
Alert.alert("Missing backend URL", "Enter the backend endpoint first.");
return;
}
try {
setIsUploading(true);
setStatusMessage("Uploading recording.");
setResponsePreview("");
const mimeType = buildMimeType(targetUri);
const extension = buildFileExtension(targetUri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: targetUri,
} as never);
const headers: Record<string, string> = {};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(trimmedUrl, {
const response = await fetch(commandUrl, {
method: "POST",
headers,
body: formData,
body: JSON.stringify({ text }),
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
setTranscript("");
setInterimTranscript("");
if (!response.ok) {
throw new Error(`Upload failed with ${response.status}. ${responseText}`);
throw new Error(`${response.status}. ${responseText}`);
}
setStatusMessage("Upload complete.");
setStatusMessage(strings.voiceMessageSent);
} catch (error) {
setStatusMessage("Upload failed.");
setStatusMessage(strings.uploadFailed);
Alert.alert(
"Upload failed",
error instanceof Error ? error.message : "Unknown upload error.",
strings.uploadFailed,
error instanceof Error ? error.message : "",
);
} finally {
setIsUploading(false);
setIsSending(false);
}
}
async function handlePressIn() {
if (isSending) return;
await startListening();
}
async function handlePressOut() {
await stopListeningAndSend();
}
const releaseLabel = t("releaseToStop", locale);
const holdLabel = t("holdToRecord", locale);
const openSettingsHint = t("openSettingsHint", locale);
const appTitleLabel = t("appTitle", locale);
const recorderTitleLabel = t("recorderTitle", locale);
const serverResponseLabel = t("serverResponse", locale);
const displayText = interimTranscript || transcript;
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : undefined}
@@ -265,59 +300,90 @@ export default function RecorderScreen() {
<View style={styles.hero}>
<View style={styles.heroTopRow}>
<View style={styles.heroBadge}>
<Text style={styles.heroBadgeText}>Assistant Voice</Text>
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
</View>
<Pressable onPress={() => router.push("/settings")} style={styles.settingsLink}>
<Text style={styles.settingsLinkText}>Settings</Text>
<Pressable
onPress={() => router.push("/settings")}
hitSlop={10}
style={styles.settingsCog}
>
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<Path
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
fill="#d3deea"
/>
<Path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08Z"
stroke="#d3deea"
strokeWidth="1.5"
/>
</Svg>
</Pressable>
</View>
<Text style={styles.subtitle}>
Record a voice message and send it to your backend.
</Text>
</View>
<View style={styles.panel}>
<Text style={styles.meterValueCentered}>
{formatDuration(recordingMs)}
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
{formatDuration(listeningMs)}
</Text>
<Pressable
disabled={isUploading}
onPress={recording ? stopRecording : startRecording}
disabled={isSending}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
styles.micButton,
recording ? styles.stopButton : styles.recordButton,
isUploading && styles.buttonDisabled,
isHolding ? styles.holdingButton : styles.idleButton,
isSending && styles.buttonDisabled,
]}
>
{isUploading ? (
{isSending ? (
<ActivityIndicator color="#fff6f3" size="large" />
) : (
<Text style={styles.micButtonText}>
{recording ? "Stop" : "Record"}
</Text>
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<Path
d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"
fill="#fff6f3"
stroke="#fff6f3"
strokeWidth="1"
/>
<Path
d="M19 10v1a7 7 0 0 1-14 0v-1"
stroke="#fff6f3"
strokeWidth="2"
strokeLinecap="round"
/>
<Path
d="M12 18v3"
stroke="#fff6f3"
strokeWidth="2"
strokeLinecap="round"
/>
</Svg>
)}
</Pressable>
<Text style={styles.statusText}>{statusMessage}</Text>
<Text style={styles.statusText}>
{statusMessage || strings.readyToRecord}
</Text>
<Text style={styles.helperText}>
{backendUrl.trim()
? recording
? "Tap again when you finish speaking."
: "Tap the button to start a new voice message."
: "Open settings to add your backend URL before sending voice messages."}
{isHolding
? releaseLabel
: backendUrl.trim()
? holdLabel
: openSettingsHint}
</Text>
{responsePreview ? (
<View style={styles.responseBox}>
<Text style={styles.responseLabel}>Server response</Text>
<Text style={styles.responseText}>{responsePreview}</Text>
{displayText && (
<View style={styles.transcriptBox}>
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
<Text style={styles.transcriptText}>{displayText}</Text>
</View>
) : null}
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</View>
);
}
@@ -333,17 +399,17 @@ const styles = StyleSheet.create({
flex: 1,
},
content: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: 32,
paddingHorizontal: 20,
paddingBottom: 32,
paddingTop: 8,
gap: 18,
},
hero: {
backgroundColor: "#13304a",
borderRadius: 28,
backgroundColor: "transparent",
paddingHorizontal: 22,
paddingVertical: 24,
gap: 12,
paddingTop: 40,
},
heroTopRow: {
alignItems: "center",
@@ -351,7 +417,6 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
},
heroBadge: {
alignSelf: "flex-start",
backgroundColor: "#f2b15d",
borderRadius: 999,
paddingHorizontal: 12,
@@ -364,22 +429,14 @@ const styles = StyleSheet.create({
letterSpacing: 0.5,
textTransform: "uppercase",
},
settingsLink: {
borderColor: "#58718d",
settingsCog: {
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 7,
},
settingsLinkText: {
color: "#d3deea",
fontSize: 13,
fontWeight: "700",
},
subtitle: {
color: "#d3deea",
fontSize: 16,
lineHeight: 22,
backgroundColor: "#13304a",
marginLeft: 12,
},
panel: {
backgroundColor: "#fffaf1",
@@ -388,6 +445,8 @@ const styles = StyleSheet.create({
borderWidth: 1,
gap: 12,
padding: 18,
alignSelf: "center",
maxWidth: 340,
},
meterValueCentered: {
color: "#d04f2d",
@@ -404,16 +463,12 @@ const styles = StyleSheet.create({
width: 164,
alignSelf: "center",
},
recordButton: {
idleButton: {
backgroundColor: "#13304a",
},
holdingButton: {
backgroundColor: "#d04f2d",
},
stopButton: {
backgroundColor: "#8c1c13",
},
micButtonText: {
color: "#fff6f3",
fontSize: 24,
fontWeight: "800",
transform: [{ scale: 1.08 }],
},
buttonDisabled: {
opacity: 0.45,
@@ -422,11 +477,35 @@ const styles = StyleSheet.create({
color: "#1f2d3d",
fontSize: 15,
lineHeight: 21,
textAlign: "center",
},
helperText: {
color: "#665f54",
fontSize: 13,
lineHeight: 18,
textAlign: "center",
},
transcriptBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
maxWidth: "100%",
},
transcriptLabel: {
color: "#13304a",
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
transcriptText: {
color: "#36475a",
fontSize: 16,
lineHeight: 22,
textAlign: "center",
fontWeight: "500",
},
responseBox: {
backgroundColor: "#f7f0e0",
@@ -440,6 +519,7 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
responseText: {
color: "#36475a",

View File

@@ -1,3 +1,4 @@
import { Picker } from "@react-native-picker/picker";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import {
@@ -16,11 +17,20 @@ import {
loadRecorderSettings,
saveRecorderSettings,
} from "@/lib/recorder-settings";
import { AVAILABLE_LOCALES, t, type Locale, getStrings } from "@/lib/translations";
function localeLabel(locale: Locale) {
if (locale === "ca") return "Catal\u00e0";
if (locale === "en") return "English";
return locale;
}
export default function SettingsScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
const [language, setLanguage] = useState<Locale>("ca");
const [strings, setStrings] = useState(() => getStrings("ca"));
useEffect(() => {
let isMounted = true;
@@ -36,9 +46,11 @@ export default function SettingsScreen() {
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLanguage(settings.language);
setStrings(getStrings(settings.language));
} catch {
if (isMounted) {
Alert.alert("Settings error", "Could not load backend settings.");
Alert.alert(strings.loadError, strings.loadError);
}
}
}
@@ -50,19 +62,28 @@ export default function SettingsScreen() {
};
}, []);
const langStrings = getStrings(language);
async function handleSave() {
try {
await saveRecorderSettings({
authToken,
backendUrl,
fieldName,
language,
});
setStrings(getStrings(language));
router.back();
} catch {
Alert.alert("Save failed", "Could not save backend settings.");
Alert.alert(langStrings.saveError, langStrings.saveError);
}
}
function handleLanguageChange(val: Locale) {
setLanguage(val);
setStrings(getStrings(val));
}
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
@@ -76,54 +97,73 @@ export default function SettingsScreen() {
>
<View style={styles.headerRow}>
<Pressable onPress={() => router.back()} style={styles.navButton}>
<Text style={styles.navButtonText}>Back</Text>
<Text style={styles.navButtonText}>{langStrings.back}</Text>
</Pressable>
<Text style={styles.title}>Settings</Text>
<Text style={styles.title}>{langStrings.settingsTitle}</Text>
<Pressable onPress={handleSave} style={styles.navButton}>
<Text style={styles.navButtonText}>Save</Text>
<Text style={styles.navButtonText}>{langStrings.save}</Text>
</Pressable>
</View>
<View style={styles.panel}>
<Text style={styles.label}>Backend URL</Text>
<Text style={styles.label}>{langStrings.backendUrl}</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
onChangeText={setBackendUrl}
placeholder="https://api.example.com/upload"
placeholder={langStrings.urlPlaceholder}
placeholderTextColor="#8f8a7c"
style={styles.input}
value={backendUrl}
/>
<Text style={styles.label}>Bearer token</Text>
<Text style={styles.label}>{langStrings.bearerToken}</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setAuthToken}
placeholder="Optional"
placeholder={langStrings.tokenOptional}
placeholderTextColor="#8f8a7c"
secureTextEntry
style={styles.input}
value={authToken}
/>
<Text style={styles.label}>Form field name</Text>
<Text style={styles.label}>{langStrings.formFieldName}</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setFieldName}
placeholder="file"
placeholder={langStrings.fieldNamePlaceholder}
placeholderTextColor="#8f8a7c"
style={styles.input}
value={fieldName}
/>
<Text style={styles.helperText}>
The recording is uploaded as multipart field `{fieldName.trim() || "file"}`.
{t("helperText", language, fieldName.trim() || "file")}
</Text>
</View>
<View style={styles.panel}>
<Text style={styles.label}>{langStrings.languageTitle}</Text>
<View style={styles.pickerWrapper}>
<Picker
selectedValue={language}
onValueChange={handleLanguageChange}
style={styles.picker}
>
{AVAILABLE_LOCALES.map((loc) => (
<Picker.Item
key={loc}
label={localeLabel(loc)}
value={loc}
/>
))}
</Picker>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
@@ -199,4 +239,14 @@ const styles = StyleSheet.create({
fontSize: 13,
lineHeight: 18,
},
pickerWrapper: {
backgroundColor: "#f7f0e0",
borderColor: "#d9ccb5",
borderRadius: 16,
borderWidth: 1,
overflow: "hidden",
},
picker: {
height: 50,
},
});

View File

@@ -1,15 +1,19 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { Locale } from "./translations";
export const STORAGE_KEYS = {
authToken: "recorder.authToken",
backendUrl: "recorder.backendUrl",
fieldName: "recorder.fieldName",
language: "recorder.language",
};
export type RecorderSettings = {
authToken: string;
backendUrl: string;
fieldName: string;
language: Locale;
};
export async function loadRecorderSettings(): Promise<RecorderSettings> {
@@ -17,6 +21,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
STORAGE_KEYS.backendUrl,
STORAGE_KEYS.authToken,
STORAGE_KEYS.fieldName,
STORAGE_KEYS.language,
]);
const values = Object.fromEntries(entries);
@@ -25,6 +30,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
authToken: values[STORAGE_KEYS.authToken] ?? "",
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
language: (values[STORAGE_KEYS.language] as Locale) ?? "ca",
};
}
@@ -33,5 +39,6 @@ export async function saveRecorderSettings(settings: RecorderSettings) {
[STORAGE_KEYS.backendUrl, settings.backendUrl],
[STORAGE_KEYS.authToken, settings.authToken],
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
[STORAGE_KEYS.language, settings.language],
]);
}

View File

@@ -0,0 +1,100 @@
export type TranslationKeys = ReturnType<typeof getStrings>;
export function en() {
return {
appTitle: "Quibot Control",
settingsTitle: "Settings",
back: "Back",
save: "Save",
backendUrl: "Backend URL",
bearerToken: "Bearer token",
formFieldName: "Form field name",
tokenOptional: "Optional",
fieldNamePlaceholder: "file",
urlPlaceholder: "https://api.example.com/upload",
helperText: `The recording is uploaded as multipart field '{field}'.`,
savedAlert: "Settings saved.",
loadError: "Could not load backend settings.",
saveError: "Could not save backend settings.",
languageTitle: "Language",
recorderTitle: "Voice recorder",
readyToRecord: "Ready to record.",
recording: "Recording...",
micPermissionDenied: "Microphone permission was denied.",
micAccessRequiredTitle: "Microphone access required",
micAccessRequiredMsg: "Enable microphone access to record audio.",
couldNotStartRecording: "Could not start recording.",
recordingFailedTitle: "Recording failed",
finishedUpload: "Recording finished. Uploading...",
voiceMessageSent: "Voice message sent.",
uploadFailed: "Upload failed.",
noBackendUrl: "Recording finished. No backend URL set.",
stopFailedTitle: "Stop failed",
missingBackendUrlTitle: "Missing backend URL",
missingBackendUrlMsg: "Enter the backend endpoint first.",
uploadingRecording: "Uploading recording.",
uploadComplete: "Upload complete.",
serverResponse: "Server response",
releaseToStop: "Release your finger to stop recording and send the audio.",
holdToRecord: "Hold the microphone button to record a voice message. Release to send it immediately.",
openSettingsHint: "Open settings to add your backend URL before sending voice messages.",
};
}
export function ca() {
return {
appTitle: "Quibot Control",
settingsTitle: "Configuraci\u00f3",
back: "Enrere",
save: "Desa",
backendUrl: "URL del servidor",
bearerToken: "Bearer token",
formFieldName: "Nom del camp del formulari",
tokenOptional: "Opcional",
fieldNamePlaceholder: "file",
urlPlaceholder: "https://api.example.com/upload",
helperText: `La gravaci\u00f3 es penja com el camp multipart '{field}'.`,
savedAlert: "Configuraci\u00f3 desada.",
loadError: "No s'han pogut carregar les configuracions.",
saveError: "No s'han pogut desar les configuracions.",
languageTitle: "Llenguatge",
recorderTitle: "Enregistrador de veu",
readyToRecord: "Preparat per enregistrar.",
recording: "Enregistrant...",
micPermissionDenied: "S'ha denegat el perm\u00eds del micr\u00f2fon.",
micAccessRequiredTitle: "Acc\u00e9s al micr\u00f2fon necess\u00e0ri",
micAccessRequiredMsg: "Activa l'acc\u00e9s al micr\u00f2fon per enregistrar \u00e0udio.",
couldNotStartRecording: "No s'ha pogut iniciar l'enregistrament.",
recordingFailedTitle: "L'enregistrament ha fallat",
finishedUpload: "Gravaci\u00f3 finalitzada. S'est\u00e0 penjant...",
voiceMessageSent: "Missatge de veu enviat.",
uploadFailed: "No s'ha pogut penjar.",
noBackendUrl: "Gravaci\u00f3 finalitzada. No hi ha URL configurada.",
stopFailedTitle: "S'ha aturat",
missingBackendUrlTitle: "Falta la URL del servidor",
missingBackendUrlMsg: "Introdueix primer l'URL del servidor.",
uploadingRecording: "S'est\u00e0 penjant la gravaci\u00f3.",
uploadComplete: "Penjada completada.",
serverResponse: "Resposta del servidor",
releaseToStop: "Allibera el dit per aturar l'enregistrament i enviar l'\u00e0udio.",
holdToRecord: "Mant\u00e9s premut el micr\u00f2fon per enregistrar un missatge de veu. Allibera'l per enviar-lo immediatament.",
openSettingsHint: "Obre la configuraci\u00f3 per afegir l'URL del servidor abans d'enviar missatges de veu.",
};
}
const translations = { en, ca };
export type Locale = keyof typeof translations;
const DEFAULT_LOCALE: Locale = "ca";
export const AVAILABLE_LOCALES: readonly Locale[] = Object.keys(translations) as Locale[];
export function getStrings(locale: Locale) {
const fn = translations[locale] ?? en;
return fn();
}
export function t(key: keyof ReturnType<typeof ca>, locale: Locale, field?: string) {
const strings = getStrings(locale);
const value = strings[key as keyof typeof strings];
if (typeof value !== "string") return String(value);
return field ? value.replace("{field}", field) : value;
}

393
apk/package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@react-native-voice/voice": "^3.2.4",
"expo": "~54.0.33",
"expo-av": "~16.0.8",
"expo-router": "~6.0.23",
@@ -19,6 +21,7 @@
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5",
"react-native-web": "~0.21.0"
},
"devDependencies": {
@@ -2769,6 +2772,175 @@
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz",
"integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==",
"license": "MIT",
"workspaces": [
"example"
],
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@react-native-voice/voice": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@react-native-voice/voice/-/voice-3.2.4.tgz",
"integrity": "sha512-4i3IpB/W5VxCI7BQZO5Nr2VB0ecx0SLvkln2Gy29cAQKqgBl+1ZsCwUBChwHlPbmja6vA3tp/+2ADQGwB1OhHg==",
"deprecated": "This package is deprecated. Use expo-speech-recognition instead.",
"license": "MIT",
"dependencies": {
"@expo/config-plugins": "^2.0.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react-native": ">= 0.60.2"
}
},
"node_modules/@react-native-voice/voice/node_modules/@babel/code-frame": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
"license": "MIT",
"dependencies": {
"@babel/highlight": "^7.10.4"
}
},
"node_modules/@react-native-voice/voice/node_modules/@expo/config-plugins": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-2.0.4.tgz",
"integrity": "sha512-JGt/X2tFr7H8KBQrKfbGo9hmCubQraMxq5sj3bqDdKmDOLcE1a/EDCP9g0U4GHsa425J8VDIkQUHYz3h3ndEXQ==",
"license": "MIT",
"dependencies": {
"@expo/config-types": "^41.0.0",
"@expo/json-file": "8.2.30",
"@expo/plist": "0.0.13",
"debug": "^4.3.1",
"find-up": "~5.0.0",
"fs-extra": "9.0.0",
"getenv": "^1.0.0",
"glob": "7.1.6",
"resolve-from": "^5.0.0",
"slash": "^3.0.0",
"xcode": "^3.0.1",
"xml2js": "^0.4.23"
}
},
"node_modules/@react-native-voice/voice/node_modules/@expo/config-types": {
"version": "41.0.0",
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-41.0.0.tgz",
"integrity": "sha512-Ax0pHuY5OQaSrzplOkT9DdpdmNzaVDnq9VySb4Ujq7UJ4U4jriLy8u93W98zunOXpcu0iiKubPsqD6lCiq0pig==",
"license": "MIT"
},
"node_modules/@react-native-voice/voice/node_modules/@expo/json-file": {
"version": "8.2.30",
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-8.2.30.tgz",
"integrity": "sha512-vrgGyPEXBoFI5NY70IegusCSoSVIFV3T3ry4tjJg1MFQKTUlR7E0r+8g8XR6qC705rc2PawaZQjqXMAVtV6s2A==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "~7.10.4",
"fs-extra": "9.0.0",
"json5": "^1.0.1",
"write-file-atomic": "^2.3.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/@expo/plist": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.13.tgz",
"integrity": "sha512-zGPSq9OrCn7lWvwLLHLpHUUq2E40KptUFXn53xyZXPViI0k9lbApcR9KlonQZ95C+ELsf0BQ3gRficwK92Ivcw==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.2.3",
"xmlbuilder": "^14.0.0",
"xmldom": "~0.5.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/getenv": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz",
"integrity": "sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@react-native-voice/voice/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@react-native-voice/voice/node_modules/json5": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.0"
},
"bin": {
"json5": "lib/cli.js"
}
},
"node_modules/@react-native-voice/voice/node_modules/write-file-atomic": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz",
"integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"signal-exit": "^3.0.2"
}
},
"node_modules/@react-native-voice/voice/node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@react-native-voice/voice/node_modules/xmlbuilder": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz",
"integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -4237,6 +4409,15 @@
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
"license": "MIT"
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
"license": "ISC",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -4538,6 +4719,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -5061,6 +5248,56 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5277,6 +5514,61 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -5346,6 +5638,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -6768,7 +7072,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
@@ -6848,6 +7151,21 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz",
"integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==",
"license": "MIT",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^1.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -8225,6 +8543,27 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonfile/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8576,7 +8915,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
@@ -8736,6 +9074,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -9337,6 +9681,18 @@
"node": ">=10"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -9699,7 +10055,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
@@ -10243,6 +10598,20 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.15.5",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz",
"integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
@@ -11965,6 +12334,15 @@
"node": ">=4"
}
},
"node_modules/universalify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz",
"integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -12632,6 +13010,15 @@
"node": ">=8.0"
}
},
"node_modules/xmldom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz",
"integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -11,6 +11,8 @@
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "^2.11.4",
"@react-native-voice/voice": "^3.2.4",
"expo": "~54.0.33",
"expo-av": "~16.0.8",
"expo-router": "~6.0.23",
@@ -20,8 +22,9 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-web": "~0.21.0",
"react-native-screens": "~4.16.0"
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5",
"react-native-web": "~0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",

9
backend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Raspberry Pi connection config
RASPBERRY_PI_HOST=http://raspberrypi.local
RASPBERRY_PI_PORT=8000
# Auth token for API endpoints
QUIBOT_TOKEN=MY_SECRET_TOKEN
# Backend server config
PORT=3000

6
backend/.gitignore vendored
View File

@@ -1,2 +1,4 @@
__pycache__/
venv/
node_modules/
dist/
.env
*.log

1919
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "quibot-backend",
"version": "1.0.0",
"description": "QuiBot robot controller backend - runs on local laptop",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"axios": "^1.7.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1",
"openai": "^6.44.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.19.21",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1 @@
Col·la, pítalo, la ola, ola.

View File

@@ -0,0 +1 @@
Hola, què tal, hola, hola, hola, hola...

View File

@@ -0,0 +1 @@
Hola, que tal, bon dia.

34
backend/src/config.ts Normal file
View File

@@ -0,0 +1,34 @@
import dotenv from 'dotenv';
dotenv.config();
let _raspberryHost = process.env.RASPBERRY_PI_HOST ?? 'http://raspberrypi.local';
let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
const APP_PORT = Number(process.env.PORT) || 5000;
export const getRaspberryHost = () => _raspberryHost;
export const getRaspberryPort = () => _raspberryPort;
export const getToken = () => _token;
export function setRaspberryHost(host: string) {
_raspberryHost = host;
}
export function setRaspberryPort(port: number) {
_raspberryPort = port;
}
export function setToken(token: string) {
_token = token;
}
export const getConfig = () => ({
raspberryPi: {
host: getRaspberryHost(),
port: getRaspberryPort(),
},
token: getToken(),
});
export const getAppPort = () => APP_PORT;

View File

@@ -0,0 +1,119 @@
import { Router } from 'express';
import multer from 'multer';
import { execFile } from 'child_process';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import { writeFile, unlink } from 'fs';
import { raspiService } from '../services/raspi.service.js';
const execFileAsync = promisify(execFile);
const writeFileAsync = promisify(writeFile);
const unlinkAsync = promisify(unlink);
const router = Router();
const upload = multer({ storage: multer.memoryStorage() });
router.get('/incoming', async (_req, res) => {
try {
const result = await raspiService.listIncomingAudio();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `List incoming failed: ${message}` });
}
});
router.post('/lock/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.lockAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Lock audio failed: ${message}` });
}
});
router.post('/unlock/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.unlockAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Unlock audio failed: ${message}` });
}
});
router.post('/cancel/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.cancelAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Cancel audio failed: ${message}` });
}
});
router.post('/process/:filename', async (req, res) => {
try {
const { filename } = req.params;
const result = await raspiService.processAudio({ filename });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Process audio failed: ${message}` });
}
});
const whisperModel = process.env.WHISPER_MODEL ?? 'base';
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
router.post('/upload', upload.single('file'), async (req, res) => {
let tmpFile: string | undefined;
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const ext = req.file.originalname.split('.').pop()?.toLowerCase() || 'wav';
tmpFile = join(tmpdir(), `quibot-audio-${Date.now()}.${ext}`);
await writeFileAsync(tmpFile, req.file.buffer);
console.log(`[whisper] Model: ${whisperModel}, Language: ${whisperLanguage}, File: ${tmpFile}`);
const { stdout, stderr } = await execFileAsync('whisper', [
tmpFile,
'--model', whisperModel,
'--language', whisperLanguage,
'--output_format', 'txt',
], { maxBuffer: 50 * 1024 * 1024 });
if (stderr) {
console.log(`[whisper] stderr: ${stderr}`);
}
const transcription = stdout.trim();
res.json({
transcription,
originalFilename: req.file.originalname,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Audio transcription failed: ${message}` });
} finally {
if (tmpFile) {
try {
await unlinkAsync(tmpFile);
} catch {
// ignore cleanup errors
}
}
}
});
export default router;

View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
import { raspiService } from '../services/raspi.service.js';
const router = Router();
router.post('/', async (req, res) => {
const { task } = req.body;
if (!task) {
return res.status(400).json({ error: 'Task name is required' });
}
try {
const result = await raspiService.runTask({ task });
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Run task failed: ${message}` });
}
});
export default router;

View File

@@ -0,0 +1,53 @@
import { Router } from 'express';
import multer from 'multer';
import { raspiService } from '../services/raspi.service.js';
const router = Router();
// Multer config - store in memory for proxying to RasPi
const upload = multer({ storage: multer.memoryStorage() });
router.post('/step/forward', async (_req, res) => {
try {
const result = await raspiService.motorStepForward();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Motor step forward failed: ${message}` });
}
});
router.post('/step/backward', async (_req, res) => {
try {
const result = await raspiService.motorStepBackward();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Motor step backward failed: ${message}` });
}
});
router.post('/stop', async (_req, res) => {
try {
const result = await raspiService.motorStop();
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Motor stop failed: ${message}` });
}
});
router.post('/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No audio file provided' });
}
const result = await raspiService.uploadAudio(req.file.buffer, req.file.originalname);
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
res.status(500).json({ error: `Audio upload failed: ${message}` });
}
});
export default router;

View File

@@ -0,0 +1,25 @@
import { Router } from 'express';
import { getConfig, setRaspberryHost, setRaspberryPort, setToken } from '../config.js';
const router = Router();
router.get('/', (_req, res) => {
const settings = getConfig();
res.json(settings);
});
router.put('/', (req, res) => {
const { raspberryPi, token } = req.body;
if (raspberryPi?.host !== undefined) {
setRaspberryHost(raspberryPi.host);
}
if (raspberryPi?.port !== undefined) {
setRaspberryPort(Number(raspberryPi.port));
}
if (token !== undefined) {
setToken(token);
}
res.json(getConfig());
});
export default router;

View File

@@ -0,0 +1,101 @@
import { Router } from 'express';
import { raspiService } from '../services/raspi.service.js';
const router = Router();
const commandMap = [
// Catalan forward
{ words: ['endavant', 'avança'], action: 'forward' },
// Catalan backward
{ words: ['atras', 'enrere', 'atras'], action: 'backward' },
// Catalan left
{ words: ['esquerra', 'esquerre', 'stoppa'], action: 'left' },
// Catalan right
{ words: ['dreta', 'destre'], action: 'right' },
// Stop
{ words: ['atura', 'atura', 'pare', 'stop', 'parada', 'aturar'], action: 'stop' },
// Pick up / grab
{ words: ['recull', 'pega', 'pilla', 'agafa'], action: 'pick' },
// Eject / throw
{ words: ['llança', 'tira', 'expulsa', ' llença'], action: 'eject' },
// Spanish forward
{ words: ['adelante'], action: 'forward' },
// Spanish backward
{ words: ['atras', 'atrás', 'reversa', 'al tras'], action: 'backward' },
// Spanish left
{ words: ['izquierda', 'izq'], action: 'left' },
// Spanish right
{ words: ['derecha', 'der'], action: 'right' },
// Spanish stop
{ words: ['para', 'stop', 'pare', 'deten', 'frena', 'alto'], action: 'stop' },
];
function resolveCommand(text: string) {
const lower = text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
for (const entry of commandMap) {
for (const word of entry.words) {
const normalizedWord = word.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (lower.includes(normalizedWord)) {
return entry.action;
}
}
}
return null;
}
router.post('/text', async (req, res) => {
const { text } = req.body;
if (!text || typeof text !== 'string') {
return res.status(400).json({ error: 'Text is required' });
}
const action = resolveCommand(text);
if (!action) {
return res.json({ recognized: text.trim(), action: null, message: 'No motor command matched. Use: endavant, atras, esquerra, dreta, atura, recull, llança' });
}
try {
switch (action) {
case 'forward': {
const result = await raspiService.motorStepForward();
return res.json({ recognized: text.trim(), action: 'forward', result });
}
case 'backward': {
const result = await raspiService.motorStepBackward();
return res.json({ recognized: text.trim(), action: 'backward', result });
}
case 'left': {
await raspiService.motorStepForward();
await new Promise(r => setTimeout(r, 600));
await raspiService.motorStop();
return res.json({ recognized: text.trim(), action: 'turn-left', result: { status: 'turned left' } });
}
case 'right': {
await raspiService.motorStepBackward();
await new Promise(r => setTimeout(r, 600));
await raspiService.motorStop();
return res.json({ recognized: text.trim(), action: 'turn-right', result: { status: 'turned right' } });
}
case 'stop': {
const result = await raspiService.motorStop();
return res.json({ recognized: text.trim(), action: 'stop', result });
}
case 'pick': {
return res.json({ recognized: text.trim(), action: 'pick', message: 'Pick command received' });
}
case 'eject': {
return res.json({ recognized: text.trim(), action: 'eject', message: 'Eject command received' });
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
return res.status(500).json({
recognized: text.trim(),
action,
error: `Command execution failed: ${message}`,
});
}
});
export default router;

25
backend/src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import express from 'express';
import cors from 'cors';
import router from './routes/router.js';
import { getAppPort, getConfig } from './config.js';
const app = express();
app.use(cors());
app.use(express.json());
// Handle multipart in motor controller separately
app.use('/audio', express.json());
app.use('/motor', express.json());
app.use('/commands', express.json());
app.use(router);
app.get('/health', (_req, res) => {
const settings = getConfig();
res.json({ status: 'ok', settings });
});
app.listen(getAppPort(), () => {
console.log(`QuiBot backend listening on port ${getAppPort()}`);
});

View File

@@ -0,0 +1,16 @@
import { Router } from 'express';
import motorController from '../controllers/motor.controller.js';
import audioController from '../controllers/audio.controller.js';
import commandController from '../controllers/command.controller.js';
import settingsController from '../controllers/settings.controller.js';
import textCommandController from '../controllers/text-command.controller.js';
const router = Router();
router.use('/motor', motorController);
router.use('/audio', audioController);
router.use('/commands', commandController);
router.use('/commands/text', textCommandController);
router.use('/settings', settingsController);
export default router;

View File

@@ -0,0 +1,77 @@
import axios from 'axios';
import { getRaspberryHost, getRaspberryPort, getToken } from '../config.js';
interface RunTaskParams {
task: string;
}
interface AudioLockParams {
filename: string;
}
export const raspiService = {
async runTask(params: RunTaskParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/run`, null, {
params: { task: params.task, token: getToken() },
});
return res.data;
},
async motorStepForward() {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/forward`, null, {
params: { token: getToken() },
});
return res.data;
},
async motorStepBackward() {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/backwards`, null, {
params: { token: getToken() },
});
return res.data;
},
async motorStop() {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/stop`, null, {
params: { token: getToken() },
});
return res.data;
},
async uploadAudio(buffer: Buffer, filename?: string) {
const fname = filename || 'audio.wav';
const ext = fname.split('.').pop()?.toLowerCase() || 'wav';
const formData = new FormData();
formData.append('file', new Blob([buffer]), fname);
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/upload`, formData, {
params: { format: ext },
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
async listIncomingAudio() {
const res = await axios.get(`${getRaspberryHost()}:${getRaspberryPort()}/audio/incoming`);
return res.data;
},
async lockAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/lock/${params.filename}`);
return res.data;
},
async unlockAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/unlock/${params.filename}`);
return res.data;
},
async cancelAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/cancel/${params.filename}`);
return res.data;
},
async processAudio(params: AudioLockParams) {
const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/process/${params.filename}`);
return res.data;
},
};

20
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

BIN
igualtat_h3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
logo-qui-bot-capcalera.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

389
package-lock.json generated Normal file
View File

@@ -0,0 +1,389 @@
{
"name": "quibot",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"vue-i18n": "^11.4.5"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.5.tgz",
"integrity": "sha512-lja3F/iKVIvTa48mIwmrIeDcQUFZ0F0drvFvT8AwINOvbwnAzl/S/p8p2DxILZpWEUHRi1qewfWNIkMvhD3kKA==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.5",
"@intlify/message-compiler": "11.4.5",
"@intlify/shared": "11.4.5"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.5.tgz",
"integrity": "sha512-W5vydP9Yq3t82IyWqCM6aR0BTWCZrN5RAwjZEPpH8I2OQWp2RLy03Evh2ANZlSMhcvGAoyDg25k0so85Kwncpw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.5",
"@intlify/shared": "11.4.5"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.5.tgz",
"integrity": "sha512-IEOZiHtbQopyPc/Dz2M869lOlZYX1SdcniNJwphATDYHhovvIneEKf1EFF37DE7NAABZtza1FNtnwwqZWInfpw==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.5",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.5.tgz",
"integrity": "sha512-g/i5mtdUa9ia/8BaJ4w6ZRHgAXYQd9XyCaQPRMvsd8d5qmZwkjoTmHrNsI28Q/7I8h+2ijUkI4uEnnMCziKupQ==",
"license": "MIT",
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT",
"peer": true
},
"node_modules/@vue/compiler-core": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.38",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.38",
"@vue/shared": "3.5.38"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.38",
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.15",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/shared": "3.5.38"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.38"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.38",
"@vue/shared": "3.5.38"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.38",
"@vue/runtime-core": "3.5.38",
"@vue/shared": "3.5.38",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38"
},
"peerDependencies": {
"vue": "3.5.38"
}
},
"node_modules/@vue/shared": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"license": "MIT",
"peer": true
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT",
"peer": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC",
"peer": true
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vue": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-sfc": "3.5.38",
"@vue/runtime-dom": "3.5.38",
"@vue/server-renderer": "3.5.38",
"@vue/shared": "3.5.38"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-i18n": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.5.tgz",
"integrity": "sha512-rm8YJ6RpjOrkcgS2GLrZwLvs/VbhxbTSuEspbyXDo233+fPK0OMFNLOj3fdQYVKdOgcpSfLW91JhbqgpkkcBWA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.5",
"@intlify/devtools-types": "11.4.5",
"@intlify/shared": "11.4.5",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"vue-i18n": "^11.4.5"
}
}

67
quibot-web/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Quibot Web
Interfície web per controlar el robit de la Quibot. Construida amb [Nuxt 3](https://nuxt.com/) i [Vue 3](https://vuejs.org/).
## Requisits previs
- [Node.js](https://nodejs.org/) (versió 20 o superior)
- npm, yarn o pnpm
## Instal·lació
```bash
npm install
```
## Execució en mode desenvolupament
```bash
npm run dev
```
L'aplicació estarà disponible a `http://localhost:3000`.
## Configuració
L'aplicació es configura mitjançant variables d'entorn:
| Variable | Descripció | Valor per defecte |
|-------------------|----------------------------------|------------------------|
| `QUIBOT_BASE_URL` | URL del backend del Quibot | `http://quibot:8000` |
| `QUIBOT_TOKEN` | Token d'autenticació del backend | `MY_SECRET_TOKEN` |
## Comandos disponibles
| Comando | Descripció |
|-----------------|--------------------------------------------|
| `npm run dev` | Inicia el servidor de desenvolupament |
| `npm run build` | Compila l'aplicació per a producció |
| `npm run generate` | Genera l'aplicació estàtica |
| `npm run preview` | Previsualitza la versió de producció |
## Funcionalitat
L'interfície permet enviar comandes al motor del Quibot:
- **Step Forward** Mou el motor cap endavant
- **Step Backwards** Mou el motor cap enrere
- **Stop** Atura el motor
## Estructura del projecte
```
quibot-web/
├── app/
│ └── app.vue # Pàgina principal amb els controls del motor
├── server/
│ └── api/motor/ # Rutes API del servidor
│ ├── stop.post.ts # Comanda d'aturada
│ └── step/[direction].post.ts # Comandes de moviment
├── public/
│ └── favicon.ico # Icona del navegador
├── nuxt.config.ts # Configuració del Nuxt
├── package.json
└── tsconfig.json # Configuració de TypeScript
```
## Llicència

19
quibot-web/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# generated inline modules
app/src/main/java/inline/
# Bundle artifacts
*.jsbundle

View File

@@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.arandano69.quibotweb'
defaultConfig {
applicationId 'com.arandano69.quibotweb'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

View File

@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,26 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,61 @@
package com.arandano69.quibotweb
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@@ -0,0 +1,45 @@
package com.arandano69.quibotweb
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
)
}
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,4 @@
<resources>
<color name="splashscreen_background">#FFFFFF</color>
<color name="colorPrimary">#023c69</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">quibot-web</string>
</resources>

View File

@@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
</style>
</resources>

View File

@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

View File

@@ -0,0 +1,63 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
expo.inlineModules.watchedDirectories=[]

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
quibot-web/android/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

98
quibot-web/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,98 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'quibot-web'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

5
quibot-web/app.json Normal file
View File

@@ -0,0 +1,5 @@
{
"android": {
"package": "com.arandano69.quibotweb"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
export default {
header: {
subtitle: 'Monitor de Control del Robot',
online: 'EN LÍNIA',
offline: 'DESCONNECTAT',
},
panels: {
blockQueue: 'Cua de Blocs',
motionControls: 'Controls de Moviment',
eyeControls: 'Controls dels Ulls',
gestureSensor: 'Sensor de Gestos',
},
blocks: {
empty: 'No hi ha blocs a la cua',
clear: 'Buidar Cua',
queued: 'A l\'espera',
processing: 'Procesant',
},
blockActions: {
rd: 'Avançar fins al creuament',
gn: 'Girar a la dreta 90\u00B0',
bu: 'Girar a l\'esquerra 90\u00B0',
ye: 'Agafar / Extrair líquid',
og: 'Deixar / Dispensar líquid',
vt: 'Pausa sorpresa',
},
colors: {
red: 'Vermell',
green: 'Verd',
blue: 'Blau',
yellow: 'Groc',
orange: 'Taronja',
violet: 'Violeta',
},
motion: {
forward: 'Endavant',
backward: 'Enrere',
left: 'Esquerra',
right: 'Dreta',
sending: 'Enviant: {dir}',
sent: 'Comanda de motor enviada: {dir}',
failed: 'La comanda ha fallat',
},
motionToast: {
success: 'Moviment {dir}',
},
eyes: {
shapeLabel: 'Color dels Ulls',
actionsLabel: 'Accions',
applyShape: 'Aplicar Forma',
applyColor: 'Aplicar Color',
eyeOn: 'Encendre',
eyeOff: 'Apagar',
toastSetShape: 'Forma dels ulls establerta: {shape}',
toastFailedShape: 'No s\'ha pogut establir la forma dels ulls',
toastSetColor: 'Color dels ulls establert: {color}',
toastFailedColor: 'No s\'ha pogut establir el color dels ulls',
toastOn: 'Ulls encesos',
toastOff: 'Ulls apagats',
toastFailedOn: 'No s\'han pogut encendre els ulls',
toastFailedOff: 'No s\'han pogut apagar els ulls',
},
eyeShapes: {
open: 'Oberts',
fw: 'Endavant',
down: 'Baix',
gesture: 'Gest',
},
eyeColors: {
white: 'Blanc',
red: 'Vermell',
green: 'Verd',
blue: 'Blau',
yellow: 'Groc',
orange: 'Taronja',
purple: 'Lila',
cyan: 'Cian',
black: 'Apagat',
},
gestures: {
modeLabel: 'Mode de Funcionament',
blockMode: 'Mode Blocs',
gestureMode: 'Mode Gestos',
detectedLabel: 'Gestos Detectats',
empty: 'Encara no s\'han detectat gestos',
reference: 'Referència de Gestos',
clearLog: 'Buidar Registre',
toggleToast: 'Canviar mode',
},
gestureNames: {
forward: 'Empènyer Endavant',
left: 'Ona Esquerra',
right: 'Ona Dreta',
up: 'Ona Amunt',
down: 'Ona Avall',
clockwise: 'Cercle CW',
anticlockwise: 'Cercle CCW',
wave: 'Ona (Commute)',
},
gestureActions: {
forward: 'Avançar creuament',
right: 'Girar a la dreta 90\u00B0',
left: 'Girar a l\'esquerra 90\u00B0',
up: 'Agafar / Extreure',
down: 'Deixar / Dispensar',
clockwise: 'Inactiu',
anticlockwise: 'Inactiu',
wave: 'Canviar mode',
},
gestureRef: {
forward: 'Empènyer Endavant',
left: 'Ona Esquerra',
right: 'Ona Dreta',
up: 'Ona Amunt',
down: 'Ona Avall',
clockwise: 'Cercle CW',
anticlockwise: 'Cercle CCW',
wave: 'Ona (Ambdues)',
},
modes: {
block: 'Mode Blocs',
gesture: 'Mode Gestos',
},
toast: {
cleared: 'Cua buidada',
switched: 'Canviat a mode {mode}',
},
theme: {
light: 'Canviar a mode clar',
dark: 'Canviar a mode fosc',
},
settings: {
title: 'Configuració',
save: 'Desar',
saved: 'Configuració desada',
theme: {
label: 'Tema',
dark: 'Fosc',
light: 'Clar',
},
language: {
label: 'Idioma',
},
piUrl: {
label: 'URL del Raspberry Pi',
placeholder: 'http://raspberrypi.local:8000',
},
},
}

View File

@@ -0,0 +1,149 @@
export default {
header: {
subtitle: 'Robot Control Monitor',
online: 'ONLINE',
offline: 'OFFLINE',
},
panels: {
blockQueue: 'Block Queue',
motionControls: 'Motion Controls',
eyeControls: 'Eye Controls',
gestureSensor: 'Gesture Sensor',
},
blocks: {
empty: 'No blocks in queue',
clear: 'Clear Queue',
queued: 'Queued',
processing: 'Processing',
},
blockActions: {
rd: 'Advance to crossing',
gn: 'Turn right 90\u00B0',
bu: 'Turn left 90\u00B0',
ye: 'Take / Extract liquid',
og: 'Leave / Dispense liquid',
vt: 'Surprise pause',
},
colors: {
red: 'Red',
green: 'Green',
blue: 'Blue',
yellow: 'Yellow',
orange: 'Orange',
violet: 'Violet',
},
motion: {
forward: 'Forward',
backward: 'Back',
left: 'Left',
right: 'Right',
sending: 'Sending: {dir}',
sent: 'Motor command sent: {dir}',
failed: 'Command failed',
},
motionToast: {
success: 'Motion {dir}',
},
eyes: {
shapeLabel: 'Eye Color',
actionsLabel: 'Actions',
applyShape: 'Apply Shape',
applyColor: 'Apply Color',
eyeOn: 'Turn On',
eyeOff: 'Turn Off',
toastSetShape: 'Eye shape set: {shape}',
toastFailedShape: 'Failed to set eye shape',
toastSetColor: 'Eye color set: {color}',
toastFailedColor: 'Failed to set eye color',
toastOn: 'Eyes turned on',
toastOff: 'Eyes turned off',
toastFailedOn: 'Failed to turn eyes on',
toastFailedOff: 'Failed to turn eyes off',
},
eyeShapes: {
open: 'Open',
fw: 'Forward',
down: 'Down',
gesture: 'Gesture',
},
eyeColors: {
white: 'White',
red: 'Red',
green: 'Green',
blue: 'Blue',
yellow: 'Yellow',
orange: 'Orange',
purple: 'Purple',
cyan: 'Cyan',
black: 'Off',
},
gestures: {
modeLabel: 'Operating Mode',
blockMode: 'Block Mode',
gestureMode: 'Gesture Mode',
detectedLabel: 'Detected Gestures',
empty: 'No gestures detected yet',
reference: 'Gesture Reference',
clearLog: 'Clear Log',
toggleToast: 'Toggle mode',
},
gestureNames: {
forward: 'Push Forward',
left: 'Wave Left',
right: 'Wave Right',
up: 'Wave Up',
down: 'Wave Down',
clockwise: 'Circle CW',
anticlockwise: 'Circle CCW',
wave: 'Wave (Toggle)',
},
gestureActions: {
forward: 'Advance crossing',
right: 'Turn right 90\u00B0',
left: 'Turn left 90\u00B0',
up: 'Take / Extract',
down: 'Leave / Dispense',
clockwise: 'Idle',
anticlockwise: 'Idle',
wave: 'Toggle mode',
},
gestureRef: {
forward: 'Push Forward',
left: 'Wave Left',
right: 'Wave Right',
up: 'Wave Up',
down: 'Wave Down',
clockwise: 'Circle CW',
anticlockwise: 'Circle CCW',
wave: 'Wave (Both)',
},
modes: {
block: 'Block Mode',
gesture: 'Gesture Mode',
},
toast: {
cleared: 'Queue cleared',
switched: 'Switched to {mode} mode',
},
theme: {
light: 'Switch to light mode',
dark: 'Switch to dark mode',
},
settings: {
title: 'Settings',
save: 'Save',
saved: 'Settings saved',
theme: {
label: 'Theme',
dark: 'Dark',
light: 'Light',
},
language: {
label: 'Language',
},
piUrl: {
label: 'Raspberry Pi URL',
placeholder: 'http://raspberrypi.local:8000',
},
},
}

View File

@@ -0,0 +1,149 @@
export default {
header: {
subtitle: 'Monitor de Control del Robot',
online: 'EN LÍNEA',
offline: 'DESCONNECTADO',
},
panels: {
blockQueue: 'Cola de Bloques',
motionControls: 'Controles de Movimiento',
eyeControls: 'Controles de los Ojos',
gestureSensor: 'Sensor de Gestos',
},
blocks: {
empty: 'No hay bloques en la cola',
clear: 'Borrar Cola',
queued: 'En espera',
processing: 'Procesando',
},
blockActions: {
rd: 'Avanzar hasta el cruce',
gn: 'Girar a la derecha 90\u00B0',
bu: 'Girar a la izquierda 90\u00B0',
ye: 'Tomar / Extraer líquido',
og: 'Dejar / Dispensar líquido',
vt: 'Pausa sorpresa',
},
colors: {
red: 'Rojo',
green: 'Verde',
blue: 'Azul',
yellow: 'Amarillo',
orange: 'Naranja',
violet: 'Violeta',
},
motion: {
forward: 'Adelante',
backward: 'Atrás',
left: 'Izquierda',
right: 'Derecha',
sending: 'Enviando: {dir}',
sent: 'Comando de motor enviado: {dir}',
failed: 'El comando falló',
},
motionToast: {
success: 'Movimiento {dir}',
},
eyes: {
shapeLabel: 'Color de los Ojos',
actionsLabel: 'Acciones',
applyShape: 'Aplicar Forma',
applyColor: 'Aplicar Color',
eyeOn: 'Encender',
eyeOff: 'Apagar',
toastSetShape: 'Forma de ojos establecida: {shape}',
toastFailedShape: 'No se pudo establecer la forma de los ojos',
toastSetColor: 'Color de ojos establecido: {color}',
toastFailedColor: 'No se pudo establecer el color de los ojos',
toastOn: 'Ojos encendidos',
toastOff: 'Ojos apagados',
toastFailedOn: 'No se pudieron encender los ojos',
toastFailedOff: 'No se pudieron apagar los ojos',
},
eyeShapes: {
open: 'Abiertos',
fw: 'Adelante',
down: 'Abajo',
gesture: 'Gesto',
},
eyeColors: {
white: 'Blanco',
red: 'Rojo',
green: 'Verde',
blue: 'Azul',
yellow: 'Amarillo',
orange: 'Naranja',
purple: 'Morado',
cyan: 'Cian',
black: 'Apagado',
},
gestures: {
modeLabel: 'Modo de Funcionamiento',
blockMode: 'Modo Bloques',
gestureMode: 'Modo Gestos',
detectedLabel: 'Gestos Detectados',
empty: 'Aún no se han detectado gestos',
reference: 'Referencia de Gestos',
clearLog: 'Borrar Registro',
toggleToast: 'Cambiar modo',
},
gestureNames: {
forward: 'Empujar Adelante',
left: 'Onda Izquierda',
right: 'Onda Derecha',
up: 'Onda Arriba',
down: 'Onda Abajo',
clockwise: 'Círculo CW',
anticlockwise: 'Círculo CCW',
wave: 'Onda (Conmute)',
},
gestureActions: {
forward: 'Avanzar cruce',
right: 'Girar a la derecha 90\u00B0',
left: 'Girar a la izquierda 90\u00B0',
up: 'Tomar / Extraer',
down: 'Dejar / Dispensar',
clockwise: 'Inactivo',
anticlockwise: 'Inactivo',
wave: 'Cambiar modo',
},
gestureRef: {
forward: 'Empujar Adelante',
left: 'Onda Izquierda',
right: 'Onda Derecha',
up: 'Onda Arriba',
down: 'Onda Abajo',
clockwise: 'Círculo CW',
anticlockwise: 'Círculo CCW',
wave: 'Onda (Ambas)',
},
modes: {
block: 'Modo Bloques',
gesture: 'Modo Gestos',
},
toast: {
cleared: 'Cola borrada',
switched: 'Cambiado a modo {mode}',
},
theme: {
light: 'Cambiar a modo claro',
dark: 'Cambiar a modo oscuro',
},
settings: {
title: 'Configuración',
save: 'Guardar',
saved: 'Configuración guardada',
theme: {
label: 'Tema',
dark: 'Oscuro',
light: 'Claro',
},
language: {
label: 'Idioma',
},
piUrl: {
label: 'URL de Raspberry Pi',
placeholder: 'http://raspberrypi.local:8000',
},
},
}

View File

@@ -0,0 +1,38 @@
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
import { createI18n } from 'vue-i18n'
import en from '~/locales/en'
import ca from '~/locales/ca'
import es from '~/locales/es'
const locales = ['en', 'ca', 'es'] as const
type LocaleType = (typeof locales)[number]
function getSavedLocale(): LocaleType {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('quibot-locale')
if (saved && locales.includes(saved as LocaleType)) {
return saved as LocaleType
}
}
return 'en'
}
export default defineNuxtPlugin((nuxtApp) => {
const locale = getSavedLocale()
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: 'en',
messages: { en, ca, es },
})
nuxtApp.vueApp.use(i18n)
// Expose setLocale for global access
nuxtApp.provide('setLocale', (l: LocaleType) => {
if (locales.includes(l)) {
i18n.global.locale.value = l
localStorage.setItem('quibot-locale', l)
}
})
})

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { locale } = useI18n()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
toast: [msg: string, type: 'success' | 'error']
}>()
const localShow = ref(props.show)
watch(() => props.show, (val) => {
localShow.value = val
})
const selectedTheme = ref(localStorage.getItem('quibot-theme') || 'dark')
const selectedLanguage = ref(localStorage.getItem('quibot-locale') || 'en')
const piUrl = ref(localStorage.getItem('quibot-pi-url') || 'http://raspberrypi.local:8000')
function applyTheme(theme: string) {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('quibot-theme', theme)
}
function saveSettings() {
applyTheme(selectedTheme.value)
locale.value = selectedLanguage.value as any
localStorage.setItem('quibot-locale', selectedLanguage.value)
localStorage.setItem('quibot-pi-url', piUrl.value)
document.cookie = `quibot-pi-url=${encodeURIComponent(piUrl.value)};path=/`
emit('toast', t('settings.saved'), 'success')
emit('update:show', false)
}
function close() {
localShow.value = false
emit('update:show', false)
}
function backdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('settings-backdrop')) {
close()
}
}
</script>
<template>
<Teleport to="body">
<div v-show="localShow" class="settings-backdrop" @click="backdropClick">
<div class="settings-modal">
<div class="settings-header">
<h2>{{ $t('settings.title') }}</h2>
<button class="close-btn" @click="close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<!-- Theme -->
<div class="setting-row">
<span class="setting-label">{{ $t('settings.theme.label') }}</span>
<div class="theme-options">
<button
:class="['theme-option', { active: selectedTheme === 'light' }]"
@click="selectedTheme = 'light'"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
<span>{{ $t('settings.theme.light') }}</span>
</button>
<button
:class="['theme-option', { active: selectedTheme === 'dark' }]"
@click="selectedTheme = 'dark'"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2m0 18v2m11-11h-2M3 12H1m17.07-7.07l-1.41 1.41M6.34 17.66l-1.41 1.41m14.14 0l-1.41-1.41M6.34 6.34L4.93 4.93"/></svg>
<span>{{ $t('settings.theme.dark') }}</span>
</button>
</div>
</div>
<!-- Language -->
<div class="setting-row">
<span class="setting-label">{{ $t('settings.language.label') }}</span>
<select v-model="selectedLanguage" class="lang-select">
<option value="en">English</option>
<option value="ca">Català</option>
<option value="es">Español</option>
</select>
</div>
<!-- PI URL -->
<div class="setting-row">
<span class="setting-label">{{ $t('settings.piUrl.label') }}</span>
<input
v-model="piUrl"
:placeholder="$t('settings.piUrl.placeholder')"
type="text"
class="url-input"
/>
</div>
<!-- Save -->
<button class="btn-save" @click="saveSettings">{{ $t('settings.save') }}</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
.settings-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
transition: opacity 0.15s ease;
}
.settings-backdrop[style*="display: block"],
.settings-backdrop[style*="display:flex"] {
display: flex !important;
opacity: 1;
}
.settings-modal {
background: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 1rem;
padding: 1.5rem;
width: 90%;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.settings-header h2 {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.close-btn {
display: grid;
place-content: center;
width: 32px;
height: 32px;
padding: 0;
background: var(--btn-bg);
border: 2px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.close-btn:hover {
border-color: #ef4444;
color: #ef4444;
}
.setting-row {
margin-bottom: 1.25rem;
}
.setting-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.theme-options {
display: flex;
gap: 0.5rem;
}
.theme-option {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
font-size: 0.8rem;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 0.625rem;
cursor: pointer;
transition: all 0.15s ease;
}
.theme-option.active {
border-color: var(--accent);
color: var(--accent);
background: var(--active-bg);
}
.theme-option:not(:disabled):hover {
border-color: var(--border-subtle);
color: var(--text-primary);
}
.lang-select,
.url-input {
width: 100%;
padding: 0.625rem 0.75rem;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-primary);
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 0.625rem;
outline: none;
transition: border-color 0.15s ease;
box-sizing: border-box;
}
.lang-select:focus,
.url-input:focus {
border-color: var(--accent);
}
.url-input::placeholder {
color: var(--text-ghost);
}
.btn-save {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-weight: 700;
font-family: inherit;
color: #fff;
background: linear-gradient(135deg, var(--accent), #ea580c);
border: none;
border-radius: 0.625rem;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 0.5rem;
}
.btn-save:hover {
box-shadow: 0 4px 16px var(--accent-glow);
transform: translateY(-1px);
}
</style>

View File

@@ -5,6 +5,10 @@ export default defineNuxtConfig({
runtimeConfig: {
quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000',
quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN',
public: {
defaultLocale: 'en',
supportedLocales: ['en', 'ca', 'es'],
}
},
vite: {
optimizeDeps: {

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,14 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"expo-speech-recognition": "^56.0.1",
"nuxt": "^4.4.2",
"react-native-svg": "^15.15.5",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
"vue-router": "^5.0.4",
"expo": "~56.0.12",
"react": "19.2.3",
"react-native": "0.85.3"
},
"devDependencies": {
}
"version": "1.0.0"
}

View File

@@ -1,5 +1,4 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const direction = getRouterParam(event, 'direction')
if (direction !== 'forward' && direction !== 'backwards') {
@@ -9,7 +8,10 @@ export default defineEventHandler(async (event) => {
})
}
return await $fetch(`${config.quibotBaseUrl}/motor/step/${direction}`, {
const baseUrl = getPiBaseUrl(event)
const config = useRuntimeConfig()
return await $fetch(`${baseUrl}/motor/step/${direction}`, {
method: 'POST',
query: {
token: config.quibotToken,

View File

@@ -1,7 +1,8 @@
export default defineEventHandler(async () => {
export default defineEventHandler(async (event) => {
const baseUrl = getPiBaseUrl(event)
const config = useRuntimeConfig()
return await $fetch(`${config.quibotBaseUrl}/motor/stop`, {
return await $fetch(`${baseUrl}/motor/stop`, {
method: 'POST',
query: {
token: config.quibotToken,

View File

@@ -0,0 +1,10 @@
export default defineNitroPlugin((nitroApp) => {
const base = useRuntimeConfig().quibotBaseUrl
nitroApp.hooks.hook('request', (event) => {
const url = getCookie(event, 'quibot-pi-url')
if (url && url !== decodeURIComponent(base)) {
// Override the base URL for this request
(event.context as any).__piBaseUrl = url
}
})
})

View File

@@ -0,0 +1,9 @@
import { getCookie } from 'h3'
export function getPiBaseUrl(event: any): string {
const cookieUrl = getCookie(event, 'quibot-pi-url')
if (cookieUrl) return decodeURIComponent(cookieUrl)
const config = useRuntimeConfig()
return config.quibotBaseUrl
}

2
raspi/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
venv/

160
raspi/blocks.py Normal file
View File

@@ -0,0 +1,160 @@
"""
blocks.py — Lectura del sensor de color TCS34725 i servo d'expulsió de blocs.
Equivalent a blocks.cpp del codi Arduino/ESP32.
Requereix /boot/config.txt:
dtoverlay=i2c-gpio,bus=4,i2c_gpio_sda=22,i2c_gpio_scl=27
"""
import time
import pigpio
import adafruit_extended_bus
import adafruit_tcs34725
from pins import SERVO_PWM
# ==================
# IDs de color
# ==================
BK = 0 # Negre (no reconegut)
RD = 1 # Vermell → avançar
GN = 2 # Verd → girar dreta
BU = 3 # Blau → girar esquerra
YE = 4 # Groc → xuclar líquid
OG = 5 # Taronja → buidar líquid
VT = 6 # Violeta → sorpresa
NUM_COLORS = 7
# Taula de colors de referència (valors RGB 0255, calibrats amb el sensor)
_COLORS = [
{"name": "BK", "r": 80, "g": 80, "b": 80},
{"name": "RD", "r": 202, "g": 32, "b": 34},
{"name": "GN", "r": 107, "g": 90, "b": 57},
{"name": "BU", "r": 104, "g": 83, "b": 66},
{"name": "YE", "r": 150, "g": 69, "b": 33},
{"name": "OG", "r": 185, "g": 44, "b": 32},
{"name": "VT", "r": 129, "g": 70, "b": 55},
]
# ==================
# Paràmetres del servo
# ==================
# Valors en µs de pulse width (pigpio set_servo_pulsewidth).
# Conversió des de l'ESP32 (16 bits, 50Hz): valor/65535 * 20000µs
# MIN = 3277/65535 * 20000 ≈ 1000µs
# MAX = 8000/65535 * 20000 ≈ 2440µs
# EJECT= 6450/65535 * 20000 ≈ 1968µs
MIN_SERVO_US = 1000 # µs → ~0°
MAX_SERVO_US = 2440 # µs → ~180°
OPEN_POSITION = 2440 # µs (equivalent a 8000 ESP32)
EJECT_POSITION = 1968 # µs (equivalent a 6450 ESP32)
_INCREMENT_US = 3 # µs per iteració de 1ms (equivalent a increment de 10 ESP32)
# ==================
# Instàncies globals
# ==================
_pi: pigpio.pi = None
_color_sensor = None
_current_servo_pos: int = OPEN_POSITION
# ==================
# Setup
# ==================
def blocks_setup(pi: pigpio.pi):
"""
Inicialitza el servo i el sensor de color TCS34725.
El servo arranca en OPEN_POSITION.
"""
global _pi, _color_sensor, _current_servo_pos
_pi = pi
# Servo
pi.set_mode(SERVO_PWM, pigpio.OUTPUT)
_current_servo_pos = OPEN_POSITION
pi.set_servo_pulsewidth(SERVO_PWM, _current_servo_pos)
# Sensor de color TCS34725 via bus I2C 4 (bit-bang GPIO22=SDA, GPIO27=SCL)
i2c = adafruit_extended_bus.ExtendedI2C(4)
_color_sensor = adafruit_tcs34725.TCS34725(i2c)
_color_sensor.integration_time = 50 # ms
_color_sensor.gain = 4 # 4x (equivalent a TCS34725::Gain::X04)
# ==================
# Servo
# ==================
def servo_move_to(target_us: int):
"""
Mou el servo fins a target_us de forma suau.
Incrementa/decrementa _INCREMENT_US cada mil·lisegon.
"""
global _current_servo_pos
if not (MIN_SERVO_US <= target_us <= MAX_SERVO_US):
return
while True:
if _current_servo_pos < target_us - _INCREMENT_US:
_current_servo_pos += _INCREMENT_US
elif _current_servo_pos > target_us + _INCREMENT_US:
_current_servo_pos -= _INCREMENT_US
else:
_current_servo_pos = target_us
_pi.set_servo_pulsewidth(SERVO_PWM, _current_servo_pos)
time.sleep(0.001)
if _current_servo_pos == target_us:
return
# ==================
# Sensor de color
# ==================
def _calc_colors_difference(measured: tuple, reference: dict) -> int:
"""Distància Manhattan entre el color mesurat i un color de referència."""
return (abs(measured[0] - reference["r"]) +
abs(measured[1] - reference["g"]) +
abs(measured[2] - reference["b"]))
def read_color_raw() -> tuple:
"""Retorna (r, g, b) en bytes 0-255 sense classificació. Útil per calibrar."""
try:
return _color_sensor.color_rgb_bytes
except Exception:
return (0, 0, 0)
def read_block_color() -> int:
"""
Llegeix el color del bloc des del sensor TCS34725.
Compara contra la taula de referència per distància Manhattan.
Retorna l'ID del color més proper, o BK si cap supera el llindar.
"""
MAX_DIFFERENCE = 15
try:
r, g, b = _color_sensor.color_rgb_bytes
except Exception:
return BK
min_difference = MAX_DIFFERENCE
min_diff_color = BK
for color_id, ref in enumerate(_COLORS):
diff = _calc_colors_difference((r, g, b), ref)
if diff < min_difference:
min_difference = diff
min_diff_color = color_id
return min_diff_color

273
raspi/eyes.py Normal file
View File

@@ -0,0 +1,273 @@
"""
eyes.py — Control de les matrius LED 8x8 RGB WS2811 (ulls del robot).
Equivalent a eyes.cpp del codi Arduino/ESP32.
FastLED → pigpio waveforms (GPIO26, qualsevol pin).
REQUISIT: iniciar el dimoni pigpio amb resolució d'1µs:
sudo pigpiod -s 1
Si s'inicia sense -s 1 (defecte 5µs), els LEDs no funcionaran correctament.
Si en el futur es fa una modificació hardware (GPIO26 → GPIO18 o GPIO21),
es pot substituir _send_ws2811() per rpi_ws281x sense canviar cap altra funció.
"""
import time
import threading
import pigpio
from pins import LED_DATA
# ==================
# Constants
# ==================
ROW_NUM = 8
COL_NUM = 8
NUM_LEDS = ROW_NUM * COL_NUM * 2 # 128 LEDs (2 matrius 8x8)
MAX_BR = 170 # Brillantor màxima del parpelleig (0255)
MIN_BR = 80 # Brillantor mínima del parpelleig
# Colors predefinits (R, G, B)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = ( 0, 255, 0)
BLUE = ( 0, 0, 255)
YELLOW = (255, 200, 0)
ORANGE = (255, 80, 0)
PURPLE = (180, 0, 255)
CYAN = ( 0, 255, 255) # Color del mode gestos
BLACK = ( 0, 0, 0)
# ==================
# Formes dels ulls (índexs dels LEDs actius)
# ==================
class EyeShape:
"""Conjunt de LEDs que formen una expressió dels ulls."""
def __init__(self, leds: list):
self.leds = leds
self.len = len(leds)
EYES_OPEN = EyeShape([
102, 89, 38, 25, 106, 101, 90, 85, 42, 37, 26, 21,
107, 100, 91, 84, 43, 36, 27, 20, 108, 99, 92, 83,
44, 35, 28, 19, 109, 98, 93, 82, 45, 34, 29, 18,
97, 94, 33, 30
])
EYES_FW = EyeShape([
103, 88, 39, 24, 105, 102, 89, 86, 41, 38, 25, 22,
117, 106, 101, 90, 85, 74, 53, 42, 37, 26, 21, 10,
123, 116, 100, 91, 75, 68, 59, 52, 36, 27, 11, 4,
99, 92, 35, 28, 98, 93, 34, 29, 97, 94, 33, 30, 96,
95, 32, 31
])
EYES_DOWN = EyeShape([97, 94, 33, 30])
# Nova forma per al mode gestos: marc extern dels dos ulls (expressió "atenta")
EYES_GESTURE = EyeShape([
96, 97, 98, 99, 100, 101, 102, 103,
104, 111, 112, 119, 120, 127,
31, 32, 33, 34, 35, 36, 37, 38, 39,
0, 7, 8, 15, 16, 23
])
# ==================
# Estat global
# ==================
_pi: pigpio.pi = None
_leds = [[0, 0, 0] for _ in range(NUM_LEDS)]
_brightness = MAX_BR
_leds_lock = threading.Lock()
_update_stop = threading.Event()
_update_thread: threading.Thread = None
# Màscara GPIO per a les waveforms de pigpio
_GPIO_MASK: int = 0
# ==================
# WS2811 via pigpio waveforms
# ==================
def _send_ws2811(data: bytes):
"""
Envia dades RGB als LEDs WS2811 via pigpio waveforms.
Ordre de color: GRB (igual que FastLED amb WS2811, GRB).
Timing a 1µs de resolució (requereix sudo pigpiod -s 1):
- Bit 0: 1µs HIGH + 2µs LOW (spec: 0.5µs + 2.0µs)
- Bit 1: 2µs HIGH + 1µs LOW (spec: 1.2µs + 1.3µs)
- Reset: 80µs LOW
"""
pulses = []
for byte_val in data:
for bit in range(7, -1, -1):
if byte_val & (1 << bit):
pulses.append(pigpio.pulse(_GPIO_MASK, 0, 2))
pulses.append(pigpio.pulse(0, _GPIO_MASK, 1))
else:
pulses.append(pigpio.pulse(_GPIO_MASK, 0, 1))
pulses.append(pigpio.pulse(0, _GPIO_MASK, 2))
pulses.append(pigpio.pulse(0, _GPIO_MASK, 80)) # reset
_pi.wave_add_new()
_pi.wave_add_generic(pulses)
wid = _pi.wave_create()
if wid >= 0:
_pi.wave_send_once(wid)
while _pi.wave_tx_busy():
pass
_pi.wave_delete(wid)
def _eyes_show(brightness: int):
"""Renderitza l'estat actual de _leds amb la brillantor indicada."""
data = bytearray(NUM_LEDS * 3)
scale = brightness / 255
for i, (r, g, b) in enumerate(_leds):
data[i * 3 + 0] = int(g * scale) # WS2811 GRB: primer G
data[i * 3 + 1] = int(r * scale)
data[i * 3 + 2] = int(b * scale)
_send_ws2811(bytes(data))
# ==================
# Setup i cleanup
# ==================
def eyes_setup(pi: pigpio.pi):
"""Inicialitza el GPIO i arrenca el thread de parpelleig."""
global _pi, _GPIO_MASK, _update_thread
_pi = pi
_GPIO_MASK = 1 << LED_DATA
pi.set_mode(LED_DATA, pigpio.OUTPUT)
pi.write(LED_DATA, 0)
_update_stop.clear()
_update_thread = threading.Thread(
target=_task_update_leds, daemon=True, name="eyes"
)
_update_thread.start()
def eyes_cleanup():
"""Atura el thread de parpelleig i apaga els LEDs."""
_update_stop.set()
if _update_thread:
_update_thread.join(timeout=1.0)
with _leds_lock:
for i in range(NUM_LEDS):
_leds[i] = [0, 0, 0]
_eyes_show(255)
# ==================
# Thread de parpelleig (equivalent a task_update_leds del FreeRTOS)
# ==================
def _task_update_leds():
"""
Bucle continu que fa respirar la brillantor dels ulls.
Equivalent a task_update_leds() del FreeRTOS.
"""
global _brightness
going_up = False # Al C++ comença a MAX_BR i baixa
_brightness = MAX_BR
while not _update_stop.is_set():
if going_up:
if _brightness < MAX_BR:
_brightness += 2
else:
going_up = False
else:
if _brightness > MIN_BR:
_brightness -= 2
else:
going_up = True
with _leds_lock:
_eyes_show(_brightness)
time.sleep(0.05)
# ==================
# Animacions (equivalent a les funcions de eyes.cpp)
# ==================
def eyes_turn_off():
"""Apaga tots els LEDs amb un fos progressiu."""
for _ in range(50):
with _leds_lock:
for i in range(NUM_LEDS):
r, g, b = _leds[i]
_leds[i] = [int(r * 245 / 255),
int(g * 245 / 255),
int(b * 245 / 255)]
time.sleep(0.01)
with _leds_lock:
for i in range(NUM_LEDS):
_leds[i] = [0, 0, 0]
def eyes_turn_on(shape: EyeShape, color: tuple,
repeat: int = 1, forward: bool = True):
"""
Encén els LEDs d'una forma un per un, amb animació.
shape: forma a dibuixar (EYES_OPEN, EYES_FW, EYES_DOWN…)
color: color RGB com a tupla (r, g, b)
repeat: nombre de vegades que es repeteix l'animació
forward: True = ordre normal, False = ordre invers
"""
r, g, b = color
for rep in range(repeat):
eyes_turn_off()
for i in range(shape.len):
idx = shape.leds[i if forward else (shape.len - 1 - i)]
with _leds_lock:
_leds[idx] = [r, g, b]
time.sleep(0.008)
if rep < repeat - 1:
for i in range(shape.len):
idx = shape.leds[i if forward else (shape.len - 1 - i)]
with _leds_lock:
_leds[idx] = [0, 0, 0]
time.sleep(0.008)
# ==================
# Animacions noves — mode gestos (TFG)
# ==================
def eyes_gesture_mode_on():
"""
Animació d'activació del mode gestos.
Parpelleig doble en cian per indicar que el robot escolta gestos.
"""
eyes_turn_on(EYES_OPEN, CYAN, repeat=2)
def eyes_gesture_mode_off():
"""
Animació de desactivació del mode gestos.
Torna als ulls oberts en blanc.
"""
eyes_turn_off()
eyes_turn_on(EYES_OPEN, WHITE)
def eyes_listening():
"""
Expressió "escoltant": marc extern dels ulls en cian.
Es mostra mentre el robot espera un gest.
"""
eyes_turn_on(EYES_GESTURE, CYAN)

238
raspi/gesture.py Normal file
View File

@@ -0,0 +1,238 @@
"""
gesture.py — Lectura del sensor de gestos PAJ7620U2 via I2C raw (smbus2).
Equivalent a gesture.cpp del codi Arduino/ESP32.
No hi ha pin INT disponible al PCB → polling cada 50ms en un thread.
Bus I2C 3 (GPIO2=SDA, GPIO1=SCL, compartit amb VL53L0X i ADS1115).
Llibreria C++ equivalent: RevEng_PAJ7620 (Aaron S. Crandall)
"""
import time
import threading
import smbus2
# ==================
# IDs de gest
# ==================
GS_NONE = 0
GS_FORWARD = 1
GS_LEFT = 2
GS_RIGHT = 3
GS_UP = 4
GS_DOWN = 5
GS_CLOCKWISE = 6
GS_ANTICLOCKWISE = 7
GS_WAVE = 8
# Àlies per al quibot.py
GS_CW = GS_CLOCKWISE
GS_CCW = GS_ANTICLOCKWISE
# ==================
# Registres PAJ7620U2
# ==================
_PAJ7620_ADDR = 0x73
_REG_BANK_SEL = 0xEF # Registre de selecció de banc (0x00=banc0, 0x01=banc1)
_REG_PART_ID_LSB = 0x00 # Ha de retornar 0x20
_REG_PART_ID_MSB = 0x01 # Ha de retornar 0x76
_REG_GESTURE_0 = 0x43 # Bits 07: Right, Left, Up, Down, Forward, Backward, CW, CCW
_REG_GESTURE_1 = 0x44 # Bit 0: Wave
# Bits de gest al registre 0x43
_BIT_RIGHT = 0x01
_BIT_LEFT = 0x02
_BIT_UP = 0x04
_BIT_DOWN = 0x08
_BIT_FORWARD = 0x10
_BIT_BACKWARD = 0x20
_BIT_CW = 0x40
_BIT_CCW = 0x80
# Bit de gest al registre 0x44
_BIT_WAVE = 0x01
# ==================
# Seqüències d'inicialització
# ==================
_INIT_BANK0 = [
(0x32, 0x29), (0x33, 0x01), (0x34, 0x00), (0x35, 0x01), (0x36, 0x00),
(0x37, 0x07), (0x38, 0x17), (0x39, 0x06), (0x3A, 0x12), (0x3F, 0x00),
(0x40, 0x02), (0x41, 0xFF), (0x42, 0x01), (0x46, 0x2D), (0x47, 0x0F),
(0x48, 0x3C), (0x49, 0x00), (0x4A, 0x1E), (0x4B, 0x00), (0x4C, 0x20),
(0x4D, 0x00), (0x4E, 0x1A), (0x4F, 0x14), (0x50, 0x00), (0x51, 0x10),
(0x52, 0x00), (0x5C, 0x02), (0x5D, 0x00), (0x5E, 0x10), (0x5F, 0x3F),
(0x60, 0x27), (0x61, 0x28), (0x62, 0x00), (0x63, 0x03), (0x64, 0xF7),
(0x65, 0x03), (0x66, 0xD9), (0x67, 0x03), (0x68, 0x01), (0x69, 0xC8),
(0x6A, 0x40), (0x6D, 0x04), (0x6E, 0x00), (0x6F, 0x00), (0x70, 0x80),
(0x71, 0x00), (0x72, 0x00), (0x73, 0x00), (0x74, 0xF0), (0x75, 0x00),
(0x80, 0x42), (0x81, 0x44), (0x82, 0x04), (0x83, 0x20), (0x84, 0x20),
(0x85, 0x00), (0x86, 0x10), (0x87, 0x00), (0x88, 0x05), (0x89, 0x18),
(0x8A, 0x10), (0x8B, 0x01), (0x8C, 0x37), (0x8D, 0x00), (0x8E, 0xF0),
(0x8F, 0x81), (0x90, 0x06), (0x91, 0x06), (0x92, 0x1E), (0x93, 0x0D),
(0x94, 0x0A), (0x95, 0x0A), (0x96, 0x0C), (0x97, 0x05), (0x98, 0x0A),
(0x99, 0x41), (0x9A, 0x14), (0x9B, 0x0A), (0x9C, 0x3F), (0x9D, 0x33),
(0x9E, 0xAE), (0x9F, 0xF9), (0xA0, 0x48), (0xA1, 0x13), (0xA2, 0x10),
(0xA3, 0x08), (0xA4, 0x30), (0xA5, 0x19), (0xA6, 0x10), (0xA7, 0x08),
(0xA8, 0x24), (0xA9, 0x04), (0xAA, 0x1E), (0xAB, 0x1E), (0xCC, 0x19),
(0xCD, 0x0B), (0xCE, 0x13), (0xCF, 0x64), (0xD0, 0x21), (0xD1, 0x0F),
(0xD2, 0x88), (0xE0, 0x01), (0xE1, 0x04), (0xE2, 0x41), (0xE3, 0xD6),
(0xE4, 0x00), (0xE5, 0x0C), (0xE6, 0x0A), (0xE7, 0x00), (0xE8, 0x00),
(0xE9, 0x00), (0xEE, 0x07),
]
_INIT_BANK1 = [
(0x00, 0x1E), (0x01, 0x1E), (0x02, 0x0F), (0x03, 0x10), (0x04, 0x02),
(0x05, 0x00), (0x06, 0xB0), (0x07, 0x04), (0x08, 0x0D), (0x09, 0x0E),
(0x0A, 0x9C), (0x0B, 0x04), (0x0C, 0x05), (0x0D, 0x0F), (0x0E, 0x02),
(0x0F, 0x12), (0x10, 0x02), (0x11, 0x02), (0x12, 0x00), (0x13, 0x01),
(0x14, 0x05), (0x15, 0x07), (0x16, 0x05), (0x17, 0x07), (0x18, 0x01),
(0x19, 0x04), (0x1A, 0x05), (0x1B, 0x0C), (0x1C, 0x2A), (0x1D, 0x01),
(0x1E, 0x00), (0x21, 0x00), (0x22, 0x00), (0x23, 0x00), (0x25, 0x01),
(0x26, 0x00), (0x27, 0x39), (0x28, 0x7F), (0x29, 0x08), (0x30, 0x03),
(0x31, 0x00), (0x32, 0x1A), (0x33, 0x1A), (0x34, 0x07), (0x35, 0x07),
(0x36, 0x01), (0x37, 0xFF), (0x38, 0x36), (0x39, 0x07), (0x3A, 0x00),
(0x3E, 0xFF), (0x3F, 0x00), (0x40, 0x77), (0x41, 0x40), (0x42, 0x00),
(0x43, 0x30), (0x44, 0xA0), (0x45, 0x5C), (0x46, 0x00), (0x47, 0x00),
(0x48, 0x58), (0x4A, 0x1E), (0x4B, 0x1E), (0x4C, 0x00), (0x4D, 0x00),
(0x4E, 0xA0), (0x4F, 0x80), (0x50, 0x00), (0x51, 0x00), (0x52, 0x00),
(0x53, 0x00), (0x54, 0x00), (0x57, 0x80), (0x59, 0x10), (0x5A, 0x08),
(0x5B, 0x94), (0x5C, 0xE8), (0x5D, 0x08), (0x5E, 0x3D), (0x5F, 0x99),
(0x60, 0x45), (0x61, 0x40), (0x63, 0x2D), (0x64, 0x02), (0x65, 0x96),
(0x66, 0x00), (0x67, 0x97), (0x68, 0x01), (0x69, 0xCD), (0x6A, 0x01),
(0x6B, 0xB0), (0x6C, 0x04), (0x6D, 0x2C), (0x6E, 0x01), (0x6F, 0x32),
(0x71, 0x00), (0x72, 0x01), (0x73, 0x35), (0x74, 0x00), (0x75, 0x33),
(0x76, 0x31), (0x77, 0x01), (0x7C, 0x84), (0x7D, 0x03), (0x7E, 0x01),
]
# ==================
# Estat global
# ==================
_bus: smbus2.SMBus = None
_gesture: int = GS_NONE
_gesture_lock = threading.Lock()
_poll_stop = threading.Event()
# ==================
# Helpers I2C
# ==================
def _select_bank(bank: int):
_bus.write_byte_data(_PAJ7620_ADDR, _REG_BANK_SEL, bank)
def _write(reg: int, val: int):
_bus.write_byte_data(_PAJ7620_ADDR, reg, val)
def _read(reg: int) -> int:
return _bus.read_byte_data(_PAJ7620_ADDR, reg)
# ==================
# Setup
# ==================
def gesture_setup():
"""
Inicialitza el PAJ7620U2 via I2C raw (smbus2, bus 3).
Arrenca el thread de polling (equivalent al ISR del C++).
"""
global _bus
_bus = smbus2.SMBus(3)
# Desperta el sensor (primer accés I2C)
try:
_bus.write_byte(_PAJ7620_ADDR, 0)
except Exception:
pass
time.sleep(0.001) # 700µs d'espera per wake-up
# Verifica ID del dispositiu
_select_bank(0)
id_lsb = _read(_REG_PART_ID_LSB)
id_msb = _read(_REG_PART_ID_MSB)
if id_lsb != 0x20 or id_msb != 0x76:
print(f"ERROR: PAJ7620U2 NOT FOUND (ID: {id_msb:02X}{id_lsb:02X})")
else:
print("Gesture sensor init OK")
# Escriu registres d'inicialització (banc 0)
_select_bank(0)
for reg, val in _INIT_BANK0:
_write(reg, val)
# Escriu registres d'inicialització (banc 1)
_select_bank(1)
for reg, val in _INIT_BANK1:
_write(reg, val)
# Torna al banc 0 per a la lectura de gestos
_select_bank(0)
# Arrenca el thread de polling
_poll_stop.clear()
threading.Thread(target=_poll_loop, daemon=True, name="gesture").start()
def gesture_cleanup():
"""Atura el polling i tanca el bus I2C."""
_poll_stop.set()
if _bus:
_bus.close()
# ==================
# Thread de polling (equivalent al ISR + flag del C++)
# ==================
def _poll_loop():
"""
Llegeix els registres de gest cada 50ms.
Equivalent a on_gesture_interrupt() + gesture_available flag del C++.
"""
global _gesture
while not _poll_stop.is_set():
try:
g0 = _read(_REG_GESTURE_0)
g1 = _read(_REG_GESTURE_1)
detected = GS_NONE
if g0 & _BIT_FORWARD: detected = GS_FORWARD
elif g0 & _BIT_LEFT: detected = GS_LEFT
elif g0 & _BIT_RIGHT: detected = GS_RIGHT
elif g0 & _BIT_UP: detected = GS_UP
elif g0 & _BIT_DOWN: detected = GS_DOWN
elif g0 & _BIT_CW: detected = GS_CLOCKWISE
elif g0 & _BIT_CCW: detected = GS_ANTICLOCKWISE
elif g1 & _BIT_WAVE: detected = GS_WAVE
if detected != GS_NONE:
with _gesture_lock:
_gesture = detected
except Exception:
pass # error I2C puntual → ignora i continua
time.sleep(0.05) # 50ms de polling (20Hz)
# ==================
# Lectura de gest
# ==================
def read_gesture() -> int:
"""
Retorna l'últim gest detectat i el reseteja a GS_NONE.
No bloquejant — equivalent a read_gesture() del C++.
"""
global _gesture
with _gesture_lock:
gest = _gesture
_gesture = GS_NONE
return gest

619
raspi/motion.py Normal file
View File

@@ -0,0 +1,619 @@
"""
motion.py — Control de motors (rodes, braços, xeringa), sensor de distància
VL53L0X i seguidor de línia TCRT5000 via ADS1115.
Equivalent a motion.cpp del codi Arduino/ESP32.
"""
import math
import time
import threading
import pigpio
import adafruit_extended_bus
import adafruit_vl53l0x
import adafruit_ads1x15.ads1115 as ADS
from adafruit_ads1x15.analog_in import AnalogIn
from pins import (
STEP_R_W, DIR_R_W, STEP_L_W, DIR_L_W, EN_W,
STEP_R_A, DIR_R_A, STEP_L_A, DIR_L_A, EN_A,
STEP_SY, DIR_SY, EN_SERVO,
END_SY, END_RA, END_LA,
)
# ==================
# Constants
# ==================
CW = True
CCW = False
ON = 0 # A4988/TB6600: enable actiu en LOW
OFF = 1
TAKE = True
LEAVE = False
# Tipus de resposta del seguidor de línia
CLEAR = 0
CROSSING = 1
OBJECT = 2
# Noms de tasques (equivalent a les constants string del C++)
MOVE_TO_CROSSING = "Move to crossing"
TURN_90_CW = "Turn 90 CW"
TURN_90_CCW = "Turn 90 CCW"
MOVE_TO_OBJECT = "Move to object"
TAKE_SOMETHING = "Take something"
LEAVE_SOMETHING = "Leave something"
DO_NOTHING = "Do nothing"
# Paràmetres de moviment
WHEELS_MAX_SPEED = 130.0 # steps/s
WHEELS_ACCEL = 190.0 # steps/s²
ARMS_MAX_SPEED = 250.0 # steps/s
ARMS_ACCEL = 125.0 # steps/s²
SYRINGE_MAX_SPEED = 800.0 # steps/s
SYRINGE_ACCEL = 500.0 # steps/s²
WHEEL_MECH_REDUCTION = 5
WHEEL_STEPS_PER_REVOLUTION = 200 * WHEEL_MECH_REDUCTION # 1000 passos/volta de roda
MM_TO_CROSSING_CENTER = 62 # mm des del creuament detectat fins al centre
MM_TO_OBJECT = 20 # mm addicionals un cop detectat l'objecte
# Llindar de negre per ADS1115 (GAIN_ONE ±4.096V, single-ended 026400 per a 3.3V).
# Equivalent a 1500/4095 de l'ESP32 de 12 bits → ~9700 en ADS1115.
BLACK_THRESHOLD = 9700
# Llindar d'error de rotació (equivalent a 20/4095 de l'ESP32 → ~130 en ADS1115).
ROTATION_ERROR_THRESHOLD = 130
# Posicions dels braços en passos des del home
ARM_LOWER_POSITION = 120
ARM_L_UPPER_POSITION = 900
ARM_R_UPPER_POSITION = 550
# Xeringa: 10 rev * 200 passos/rev * microstepping x4 = 8000 passos estesa del tot
_SY_FULL_EXTENDED_STEPS = 10 * 200 * 4
LINE_FOLLOWER_FREQ = 100 # Hz
LINE_FOLLOWER_PERIOD = 1.0 / LINE_FOLLOWER_FREQ # s
# ==================
# Classe Stepper
# ==================
class Stepper:
"""
Motor pas a pas en mode DRIVER (STEP/DIR).
Equivalent a AccelStepper(DRIVER, step_pin, dir_pin).
Genera polsos STEP via pigpio.gpio_trigger().
"""
PULSE_US = 10 # Amplada del pols STEP en µs (A4988 requereix ≥1µs)
def __init__(self, pi: pigpio.pi, step_pin: int, dir_pin: int):
self._pi = pi
self._step_pin = step_pin
self._dir_pin = dir_pin
self._pos = 0 # posició actual (passos)
self._target = 0 # posició objectiu
self._speed = 0.0 # velocitat actual (passos/s, signada)
self._max_speed = 1.0
self._accel = 1.0
self._last_step_us = self._now_us()
self._step_interval_us = 0 # 0 = aturat
pi.set_mode(step_pin, pigpio.OUTPUT)
pi.set_mode(dir_pin, pigpio.OUTPUT)
pi.write(step_pin, 0)
pi.write(dir_pin, 0)
@staticmethod
def _now_us() -> int:
return time.monotonic_ns() // 1000
def set_max_speed(self, speed: float):
self._max_speed = abs(speed)
def set_acceleration(self, accel: float):
self._accel = abs(accel)
def move_to(self, position: int):
self._target = int(position)
def move(self, relative: int):
self._target = self._pos + int(relative)
def set_current_position(self, pos: int):
self._pos = int(pos)
self._target = int(pos)
self._speed = 0.0
self._step_interval_us = 0
def current_position(self) -> int:
return self._pos
def distance_to_go(self) -> int:
return self._target - self._pos
def is_running(self) -> bool:
return self._target != self._pos
def stop(self):
self._target = self._pos
self._speed = 0.0
self._step_interval_us = 0
def set_speed(self, speed: float):
"""Estableix velocitat constant per a run_speed()."""
self._speed = float(speed)
self._step_interval_us = int(1_000_000 / abs(speed)) if speed != 0.0 else 0
def _do_step(self, direction: int):
self._pi.write(self._dir_pin, 1 if direction > 0 else 0)
self._pi.gpio_trigger(self._step_pin, self.PULSE_US, 1)
self._pos += direction
def run_speed(self) -> bool:
"""Fa un pas a velocitat constant. No bloquejant — cridar des del bucle de steppers."""
if self._step_interval_us == 0:
return False
now = self._now_us()
if now - self._last_step_us >= self._step_interval_us:
self._do_step(1 if self._speed > 0 else -1)
self._last_step_us = now
return True
return False
def run(self) -> bool:
"""
Fa un pas cap a _target amb acceleració/desacceleració.
No bloquejant — cridar des del bucle de steppers.
Implementa l'algorisme de AccelStepper: Δv = accel / v per pas.
"""
dtg = self.distance_to_go()
if dtg == 0:
self._speed = 0.0
self._step_interval_us = 0
return False
abs_speed = abs(self._speed)
if abs_speed < 1.0:
abs_speed = math.sqrt(self._accel / 2.0) # velocitat inicial AccelStepper
now = self._now_us()
if now - self._last_step_us < int(1_000_000 / abs_speed):
return False # no és hora del proper pas
# Actualitza la velocitat per al proper pas
direction = 1 if dtg > 0 else -1
stop_dist = (abs_speed ** 2) / (2.0 * self._accel) if self._accel > 0 else 0
if abs(dtg) <= max(stop_dist, 1):
new_speed = abs_speed - (self._accel / abs_speed)
new_speed = max(new_speed, 1.0)
else:
new_speed = abs_speed + (self._accel / abs_speed)
new_speed = min(new_speed, self._max_speed)
self._speed = new_speed * direction
self._do_step(direction)
self._last_step_us = now
return True
# ==================
# Instàncies globals
# ==================
_pi: pigpio.pi = None
_i2c = None
_dist_sensor = None
_ads = None
_chan_r: AnalogIn = None
_chan_l: AnalogIn = None
wheel_R: Stepper = None
wheel_L: Stepper = None
arm_R: Stepper = None
arm_L: Stepper = None
syringe: Stepper = None
wheels_speed_mode: bool = False # True → run_speed(), False → run() (posició)
_stepper_stop = threading.Event()
_stepper_thread: threading.Thread = None
# ==================
# Setup i cleanup
# ==================
def motion_setup_steppers(pi: pigpio.pi):
"""
Inicialitza GPIOs i steppers sense els sensors I2C.
Útil per a tests de motors quan VL53L0X/ADS1115 no estan connectats.
"""
global _pi, wheel_R, wheel_L, arm_R, arm_L, syringe, _stepper_thread
_pi = pi
for pin in (EN_W, EN_A, EN_SERVO):
pi.set_mode(pin, pigpio.OUTPUT)
for pin in (END_LA, END_RA, END_SY):
pi.set_mode(pin, pigpio.INPUT)
pi.set_pull_up_down(pin, pigpio.PUD_UP) # Hall actiu-baix, pull-up intern
enable_arms(OFF)
enable_wheels(OFF)
wheel_R = Stepper(pi, STEP_R_W, DIR_R_W)
wheel_L = Stepper(pi, STEP_L_W, DIR_L_W)
arm_R = Stepper(pi, STEP_R_A, DIR_R_A)
arm_L = Stepper(pi, STEP_L_A, DIR_L_A)
syringe = Stepper(pi, STEP_SY, DIR_SY)
wheel_R.set_max_speed(WHEELS_MAX_SPEED); wheel_R.set_acceleration(WHEELS_ACCEL)
wheel_L.set_max_speed(WHEELS_MAX_SPEED); wheel_L.set_acceleration(WHEELS_ACCEL)
arm_R.set_max_speed(ARMS_MAX_SPEED); arm_R.set_acceleration(ARMS_ACCEL)
arm_L.set_max_speed(ARMS_MAX_SPEED); arm_L.set_acceleration(ARMS_ACCEL)
syringe.set_max_speed(SYRINGE_MAX_SPEED); syringe.set_acceleration(SYRINGE_ACCEL)
_stepper_stop.clear()
_stepper_thread = threading.Thread(target=_stepper_loop, daemon=True, name="steppers")
_stepper_thread.start()
def motion_setup_sensors(pi: pigpio.pi):
"""
Inicialitza únicament els sensors I2C (VL53L0X, ADS1115). Sense steppers.
Útil per a tests de sensors quan els motors no estan connectats.
"""
global _pi, _i2c, _dist_sensor, _ads, _chan_r, _chan_l
_pi = pi
_i2c = adafruit_extended_bus.ExtendedI2C(3)
_dist_sensor = adafruit_vl53l0x.VL53L0X(_i2c)
_ads = ADS.ADS1115(_i2c)
_chan_r = AnalogIn(_ads, ADS.P0)
_chan_l = AnalogIn(_ads, ADS.P1)
def motion_setup(pi: pigpio.pi):
"""
Inicialitza GPIOs, steppers, sensor de distància VL53L0X,
ADC ADS1115 per als sensors de línia, i arrenca el bucle de steppers.
Requereix /boot/config.txt: dtoverlay=i2c-gpio,bus=3,i2c_gpio_sda=2,i2c_gpio_scl=1
"""
motion_setup_steppers(pi)
motion_setup_sensors(pi)
def motion_cleanup():
"""Atura el bucle de steppers i desactiva tots els motors."""
_stepper_stop.set()
if _stepper_thread:
_stepper_thread.join(timeout=1.0)
enable_wheels(OFF)
enable_arms(OFF)
enable_syringe(OFF)
# ==================
# Enable / disable
# ==================
def enable_wheels(state: bool):
_pi.write(EN_W, state)
def enable_arms(state: bool):
_pi.write(EN_A, state)
def enable_syringe(state: bool):
_pi.write(EN_SERVO, state)
def is_endstop_detecting(pin: int) -> bool:
return not _pi.read(pin) # efecte Hall actiu en baix
# ==================
# Helpers de moviment
# ==================
def mm_to_steps(mm: int) -> int:
# Perímetre roda Ø152mm = 2·π·76 ≈ 477mm
return (mm * WHEEL_STEPS_PER_REVOLUTION * 2) // 1000
def wheels_set_position(position: int = 0):
wheel_L.set_current_position(position)
wheel_R.set_current_position(position)
def wheels_set_speed(speed: float, rotate: bool = False, direction: bool = CW):
if rotate:
wheel_L.set_speed( speed if direction == CW else -speed)
wheel_R.set_speed(-speed if direction == CW else speed)
else:
wheel_L.set_speed(speed)
wheel_R.set_speed(speed)
def move_arms_to(position: int):
arm_L.move_to(position)
arm_R.move_to(position)
while arm_L.distance_to_go() != 0 and arm_R.distance_to_go() != 0:
time.sleep(0.1)
def move_arms_up():
arm_L.move_to(ARM_L_UPPER_POSITION)
arm_R.move_to(ARM_R_UPPER_POSITION)
while arm_L.distance_to_go() != 0 and arm_R.distance_to_go() != 0:
time.sleep(0.1)
def move_wheels_to(position: int, invert: bool = False):
wheel_L.move_to(position)
wheel_R.move_to(-position if invert else position)
while wheel_L.is_running() and wheel_R.is_running():
time.sleep(0.1)
# ==================
# Homing
# ==================
def arms_home():
"""Cicle de homing dels braços (bloquejant). El bucle de steppers fa el moviment."""
enable_arms(ON)
arm_L.move(-1250)
arm_R.move(-1250)
while True:
if is_endstop_detecting(END_LA):
arm_L.stop()
if is_endstop_detecting(END_RA):
arm_R.stop()
l_done = is_endstop_detecting(END_LA) or arm_L.distance_to_go() == 0
r_done = is_endstop_detecting(END_RA) or arm_R.distance_to_go() == 0
if l_done and r_done:
break
time.sleep(0.005)
arm_L.set_current_position(0)
arm_R.set_current_position(0)
arm_L.move(ARM_L_UPPER_POSITION)
arm_R.move(ARM_R_UPPER_POSITION)
while arm_L.distance_to_go() != 0 or arm_R.distance_to_go() != 0:
time.sleep(0.01)
def syringe_home():
"""Cicle de homing de la xeringa (bloquejant). El bucle de steppers fa el moviment."""
enable_syringe(ON)
syringe.move(-11000)
while not is_endstop_detecting(END_SY):
if syringe.distance_to_go() == 0:
break
time.sleep(0.005)
syringe.stop()
syringe.set_current_position(0)
enable_syringe(OFF)
# ==================
# Sensor de distància
# ==================
def distance_to_object() -> int:
"""Retorna la distància en mm a l'objecte més proper. 65535 si fora de rang."""
try:
return _dist_sensor.range
except Exception:
return 65535
# ==================
# Seguidor de línia
# ==================
def _read_line_sensors() -> tuple:
"""Llegeix sensors de línia via ADS1115. Retorna (dreta, esquerra), 032767."""
return _chan_r.value, _chan_l.value
def compute_new_speed(speed: float) -> float:
accel = WHEELS_ACCEL / LINE_FOLLOWER_FREQ
return min(speed + accel, WHEELS_MAX_SPEED)
def follow_line_loop(speed: float, forward: bool = True) -> int:
"""
Seguidor de línia no bloquejant. Retorna CLEAR, CROSSING o OBJECT.
Si CLEAR, aplica correcció proporcional a les velocitats de les rodes.
"""
distance_threshold = 50 # mm
lf_r, lf_l = _read_line_sensors()
if lf_l > BLACK_THRESHOLD and lf_r > BLACK_THRESHOLD and forward:
return CROSSING
elif distance_to_object() < distance_threshold and forward:
return OBJECT
else:
p_factor = 5
error = lf_r - lf_l
correction_r = error // p_factor
correction_l = -correction_r
wheel_L.set_speed((speed + correction_r) if forward else (-speed + correction_r))
wheel_R.set_speed((speed + correction_l) if forward else (-speed + correction_l))
return CLEAR
def run_to_crossing_center(speed: float) -> float:
"""
Avança per centrar el robot sobre el creuament detectat.
Retorna la nova velocitat (reduïda a la meitat).
"""
wheels_set_position(0 - mm_to_steps(MM_TO_CROSSING_CENTER))
while follow_line_loop(speed) == CROSSING:
wheels_set_speed(speed)
time.sleep(0.1)
speed /= 2
while wheel_L.current_position() < 0 and wheel_R.current_position() < 0:
wheels_set_speed(speed)
time.sleep(0.1)
wheels_set_speed(0)
return speed
# ==================
# Bucle de steppers (thread permanent)
# ==================
def _stepper_loop():
"""
Thread que genera els polsos STEP per a tots els motors.
Equivalent a task_update_steppers() del FreeRTOS.
S'inicia automàticament a motion_setup().
"""
while not _stepper_stop.is_set():
if wheels_speed_mode:
wheel_R.run_speed()
wheel_L.run_speed()
else:
wheel_R.run()
wheel_L.run()
arm_R.run()
arm_L.run()
syringe.run()
# ==================
# Tasques (FreeRTOS tasks → funcions bloquejants, cridades des de threads)
# ==================
def task_move_to(expected_target: int):
"""
Segueix la línia fins arribar a expected_target (CROSSING o OBJECT).
Equivalent a task_move_to() del FreeRTOS.
"""
global wheels_speed_mode
speed = 0.0
wheels_speed_mode = True
enable_wheels(ON)
lf_response = CLEAR
while True:
speed = compute_new_speed(speed)
lf_response = follow_line_loop(speed)
if lf_response != CLEAR:
break
time.sleep(LINE_FOLLOWER_PERIOD)
if lf_response == CROSSING and expected_target == CROSSING:
run_to_crossing_center(speed)
elif lf_response == OBJECT and expected_target == OBJECT:
wheel_L.stop()
wheel_R.stop()
wheels_speed_mode = False
enable_wheels(OFF)
def task_rotate(direction: bool):
"""
Gira el robot 90° (CW o CCW).
Fase 1: acceleració fins al 90% dels passos de rotació.
Fase 2: desacceleració i ajust fi sobre la línia via sensors analògics.
"""
global wheels_speed_mode
# Arc 90° a Ø250mm → 2·π·125/4 ≈ 196mm; roda Ø152mm → 196/477 ≈ 0.41 voltes
ROTATION_STEPS = (WHEEL_STEPS_PER_REVOLUTION * 42) // 100
positive_wheel = wheel_L if direction == CW else wheel_R
speed = 0.0
wheels_speed_mode = True
enable_wheels(ON)
wheels_set_position(0)
while positive_wheel.current_position() < (ROTATION_STEPS * 90) // 100:
speed = compute_new_speed(speed)
wheels_set_speed(speed, rotate=True, direction=direction)
time.sleep(0.01)
speed /= 2
while True:
lf_r, lf_l = _read_line_sensors()
error = abs(lf_l - lf_r)
if error < ROTATION_ERROR_THRESHOLD or \
positive_wheel.current_position() > (ROTATION_STEPS * 110) // 100:
break
wheels_set_speed(speed - (speed / error), rotate=True, direction=direction)
time.sleep(0.01)
wheels_set_speed(0)
enable_wheels(OFF)
wheels_speed_mode = False
def task_take_or_leave_something(take: bool):
"""
Avança fins a l'objecte, baixa braços, opera la xeringa i torna al creuament.
take=True → xucla; take=False → buida.
"""
global wheels_speed_mode
speed = 0.0
wheels_speed_mode = True
enable_wheels(ON)
wheels_set_position(0) # guardem la posició home per tornar-hi
lf_response = CLEAR
while True:
speed = compute_new_speed(speed)
lf_response = follow_line_loop(speed)
if lf_response != CLEAR:
break
time.sleep(LINE_FOLLOWER_PERIOD)
if lf_response == OBJECT:
target_l = wheel_L.current_position() + mm_to_steps(MM_TO_OBJECT)
target_r = wheel_R.current_position() + mm_to_steps(MM_TO_OBJECT)
while wheel_L.current_position() < target_l and wheel_R.current_position() < target_r:
wheels_set_speed(speed / 2)
time.sleep(0.1)
wheels_set_speed(0)
enable_wheels(OFF)
enable_arms(ON)
move_arms_to(ARM_LOWER_POSITION)
enable_syringe(ON)
syringe.move_to(_SY_FULL_EXTENDED_STEPS if take else 0)
while syringe.distance_to_go() != 0:
if not take and is_endstop_detecting(END_SY):
syringe.stop()
syringe.set_current_position(0)
break
time.sleep(0.1)
enable_syringe(OFF)
move_arms_up()
enable_wheels(ON)
wheels_speed_mode = False
wheels_set_speed(WHEELS_MAX_SPEED)
move_wheels_to(0)
elif lf_response == CROSSING:
run_to_crossing_center(speed)
wheels_speed_mode = False
enable_wheels(OFF)
def task_idle():
"""Pausa breu fins que quibot.py assigni una nova tasca."""
time.sleep(0.5)

84
raspi/pins.py Normal file
View File

@@ -0,0 +1,84 @@
"""
pins.py — Definició de pins GPIO (BCM) de la Raspberry Pi Zero 2W.
Equivalent a io.h del codi Arduino/ESP32.
Tots els números fan referència a la numeració BCM.
"""
# ==================
# MOTORS
# ==================
# Servo d'expulsió de blocs
EN_SERVO = 9
SERVO_PWM = 10
# Motor pas a pas xeringa
STEP_SY = 8
DIR_SY = 5
# Motor pas a pas roda dreta
STEP_R_W = 25
DIR_R_W = 23
# Motor pas a pas roda esquerra
STEP_L_W = 7
DIR_L_W = 11
# Enable motors de rodes (compartit)
EN_W = 6
# Motor pas a pas braç dret
STEP_R_A = 3
DIR_R_A = 4
# Motor pas a pas braç esquerre
STEP_L_A = 13
DIR_L_A = 0
# Enable motors de braços (compartit)
EN_A = 21
# ==================
# SENSORS
# ==================
# Bus I2C principal — VL53L0X (distància) i PAJ7620U2 (gestos), compartit
SDA_DIST = 2
SCL_DIST = 1
SDA_GEST = SDA_DIST # mateixa línia
SCL_GEST = SCL_DIST # mateixa línia
# INT_GEST no connectat a la PCB — el driver usa polling
# Bus I2C sensor de color TCS34725 (bit-bang)
SDA_COL = 22
SCL_COL = 27
# Final de carrera xeringa (efecte Hall)
END_SY = 12
# Final de carrera braç dret (efecte Hall)
END_RA = 16
# Final de carrera braç esquerre (efecte Hall)
END_LA = 17
# Sensors seguidors de línia (TCRT5000)
LINES_R = 14
LINES_L = 15
# ==================
# DISPLAY
# ==================
# Dades matriu LED 8x8 RGB WS2811 (2x ull)
LED_DATA = 26
# ==================
# ÀUDIO (afegit per company, no usat pel robot)
# ==================
# I2S — amplificador MAX98357A + micròfon SPH0645
I2C_BCLK = 18
I2C_LRCLK = 19
AMP_DIN = 24
MIC = 20

286
raspi/quibot.py Normal file
View File

@@ -0,0 +1,286 @@
"""
quibot.py — Programa principal del robot QuiBot H2O.
Inicialitza tots els mòduls, executa el homing i arrenca els threads.
Equivalent a QuiBot.ino del codi Arduino/ESP32.
Threads permanents:
- task_read_blocks → llegeix blocs i executa accions (aquest fitxer)
- task_read_gestures → llegeix gestos i executa accions (aquest fitxer)
- _stepper_loop → genera polsos STEP dels motors (motion.py)
- _task_update_leds → parpelleig dels LEDs (eyes.py)
- _poll_loop → polling del sensor de gestos (gesture.py)
"""
import time
import threading
import signal
import sys
import pigpio
from motion import (
motion_setup, motion_cleanup,
arms_home, syringe_home,
task_move_to, task_rotate, task_take_or_leave_something, task_idle,
distance_to_object,
CROSSING, TAKE, LEAVE, CW, CCW,
)
from blocks import (
blocks_setup,
read_block_color, servo_move_to,
OPEN_POSITION, EJECT_POSITION,
BK, RD, GN, BU, YE, OG, VT,
)
from eyes import (
eyes_setup, eyes_cleanup,
eyes_turn_on, eyes_turn_off,
eyes_gesture_mode_on, eyes_gesture_mode_off,
EYES_OPEN, EYES_FW, EYES_DOWN,
RED, GREEN, BLUE, YELLOW, ORANGE, CYAN,
)
from gesture import (
gesture_setup, gesture_cleanup,
read_gesture,
GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT,
GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE,
)
# ==================
# Colors addicionals (equivalents CRGB del C++)
# ==================
GRAY = (128, 128, 128) # CRGB::Gray → estat normal
DARK_RED = (139, 0, 0) # CRGB::DarkRed → avançar
# ==================
# Timeouts
# ==================
INSERT_BLOCK_MS = 2.0 # s — espera que l'infant insereixi el bloc
EJECT_BLOCK_MS = 2.0 # s — espera que el bloc caigui
CHECK_COLOR_MS = 1.0 # s — interval entre lectures de color
# ==================
# Estat global
# ==================
_pi: pigpio.pi = None
# Mutex que evita que tasca de blocs i tasca de gestos llancin accions simultànies.
# En el C++ original compartien TaskHandle sense mutex explícit (possible race condition).
# Aquí ho fem correctament.
_action_lock = threading.Lock()
_shutdown_event = threading.Event()
_gesture_mode_active = False # False = mode blocs, True = mode gestos
_mode_lock = threading.Lock()
# ==================
# Helper d'execució d'accions
# ==================
def _execute_action(fn, *args):
"""
Adquireix el lock d'acció, executa fn(*args) de forma bloquejant i l'allibera.
Garanteix que mai s'executen dues accions de moviment simultànies.
"""
with _action_lock:
fn(*args)
# ==================
# Tasca de blocs
# ==================
def task_read_blocks():
"""
Llegeix blocs contínuament, executa l'acció corresponent al color i expulsa el bloc.
Equivalent a task_read_blocks() del FreeRTOS.
"""
while not _shutdown_event.is_set():
eyes_state = False
eyes_turn_on(EYES_OPEN, GRAY)
# Obre el servo per permetre la inserció del bloc
servo_move_to(OPEN_POSITION)
time.sleep(INSERT_BLOCK_MS)
# Espera que hi hagi un bloc i llegeix el seu color
color_id = BK
while not _shutdown_event.is_set():
if distance_to_object() < 80:
# Objecte a menys de 80mm — esperem que es retiri
if not eyes_state:
eyes_turn_on(EYES_DOWN, GRAY)
eyes_state = True
else:
color_id = read_block_color()
if eyes_state:
eyes_turn_on(EYES_OPEN, GRAY, 1, False)
eyes_state = False
if color_id != BK:
break
time.sleep(CHECK_COLOR_MS)
# Si estem en mode gestos, ignora el bloc
with _mode_lock:
if _gesture_mode_active:
continue
# Executa l'acció corresponent al color del bloc
if color_id == RD:
eyes_turn_on(EYES_FW, DARK_RED, 2)
time.sleep(1.0)
_execute_action(task_move_to, CROSSING)
elif color_id == GN:
eyes_turn_on(EYES_OPEN, GREEN, 2)
time.sleep(1.0)
_execute_action(task_rotate, CW)
elif color_id == BU:
eyes_turn_on(EYES_OPEN, BLUE, 2)
time.sleep(1.0)
_execute_action(task_rotate, CCW)
elif color_id == YE:
eyes_turn_on(EYES_OPEN, YELLOW, 2)
time.sleep(1.0)
_execute_action(task_take_or_leave_something, TAKE)
elif color_id == OG:
eyes_turn_on(EYES_OPEN, ORANGE, 2)
time.sleep(1.0)
_execute_action(task_take_or_leave_something, LEAVE)
elif color_id == VT:
_execute_action(task_idle)
eyes_turn_on(EYES_OPEN, GRAY)
# Expulsa el bloc
servo_move_to(EJECT_POSITION)
time.sleep(EJECT_BLOCK_MS)
# ==================
# Tasca de gestos
# ==================
def task_read_gestures():
"""
Llegeix gestos contínuament.
GS_WAVE activa/desactiva el mode gestos.
Equivalent a task_read_gestures() del FreeRTOS.
"""
gesture_mode_active = False
while not _shutdown_event.is_set():
gesture_id = read_gesture()
# WAVE: toggle entre mode blocs i mode gestos
if gesture_id == GS_WAVE:
with _mode_lock:
_gesture_mode_active = not _gesture_mode_active
active = _gesture_mode_active
if active:
eyes_gesture_mode_on()
print("Gesture mode ON")
else:
eyes_gesture_mode_off()
print("Gesture mode OFF")
time.sleep(1.0)
continue
with _mode_lock:
active = _gesture_mode_active
if not active or gesture_id == GS_NONE:
time.sleep(0.1)
continue
# Executa l'acció corresponent al gest
if gesture_id == GS_FORWARD:
eyes_turn_on(EYES_FW, DARK_RED, 2)
_execute_action(task_move_to, CROSSING)
elif gesture_id == GS_RIGHT:
eyes_turn_on(EYES_OPEN, GREEN, 2)
_execute_action(task_rotate, CW)
elif gesture_id == GS_LEFT:
eyes_turn_on(EYES_OPEN, BLUE, 2)
_execute_action(task_rotate, CCW)
elif gesture_id == GS_UP:
eyes_turn_on(EYES_OPEN, YELLOW, 2)
_execute_action(task_take_or_leave_something, TAKE)
elif gesture_id == GS_DOWN:
eyes_turn_on(EYES_OPEN, ORANGE, 2)
_execute_action(task_take_or_leave_something, LEAVE)
elif gesture_id in (GS_CLOCKWISE, GS_ANTICLOCKWISE):
_execute_action(task_idle)
eyes_turn_on(EYES_OPEN, CYAN)
time.sleep(0.5)
# ==================
# Shutdown
# ==================
def _shutdown(sig, frame):
print("\nAturant QuiBot...")
_shutdown_event.set()
# ==================
# Main
# ==================
def main():
global _pi
# Connecta amb pigpiod (ha d'estar en marxa amb: sudo pigpiod -s 1)
_pi = pigpio.pi()
if not _pi.connected:
print("ERROR: No s'ha pogut connectar a pigpiod. Executa: sudo pigpiod -s 1")
sys.exit(1)
# Inicialitza tots els mòduls
blocks_setup(_pi)
motion_setup(_pi)
eyes_setup(_pi)
gesture_setup()
# Homing (bloquejant)
arms_home()
syringe_home()
# Registra els senyals de sortida
signal.signal(signal.SIGINT, _shutdown)
signal.signal(signal.SIGTERM, _shutdown)
# Arrenca els threads principals
t_blocks = threading.Thread(target=task_read_blocks, daemon=True, name="blocks")
t_gestures = threading.Thread(target=task_read_gestures, daemon=True, name="gestures")
t_blocks.start()
t_gestures.start()
print("QuiBot llest.")
# Espera senyal de sortida
_shutdown_event.wait()
# Cleanup ordenat
motion_cleanup()
eyes_cleanup()
gesture_cleanup()
_pi.stop()
print("QuiBot aturat.")
if __name__ == "__main__":
main()

147
raspi/tests/test_blocks.py Normal file
View File

@@ -0,0 +1,147 @@
"""
test_blocks.py — Tests individuals del mòdul blocks.py.
Executa des del directori Rasp/: python tests/test_blocks.py
Descomenta la funció que vols provar al final del fitxer.
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import time
import pigpio
from blocks import (
blocks_setup,
servo_move_to, OPEN_POSITION, EJECT_POSITION, MIN_SERVO_US, MAX_SERVO_US,
read_block_color, read_color_raw,
_COLORS,
BK, RD, GN, BU, YE, OG, VT,
)
_COLOR_NAMES = {
BK: "Negre (BK)",
RD: "Vermell (RD)",
GN: "Verd (GN)",
BU: "Blau (BU)",
YE: "Groc (YE)",
OG: "Taronja (OG)",
VT: "Violeta (VT)",
}
def _setup():
pi = pigpio.pi()
if not pi.connected:
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
sys.exit(1)
blocks_setup(pi)
return pi
def _teardown(pi):
pi.stop()
# ==================
# TEST 1 — Servo
# ==================
def test_servo():
"""
Mou el servo a les posicions principals: oberta, expulsió i torna a oberta.
Hauries de veure/sentir el servo moure's suaument entre posicions.
"""
print("=== TEST SERVO ===")
pi = _setup()
print(f"Movent a OPEN_POSITION ({OPEN_POSITION} µs)...")
servo_move_to(OPEN_POSITION)
time.sleep(1.0)
print("Open: OK")
print(f"Movent a EJECT_POSITION ({EJECT_POSITION} µs)...")
servo_move_to(EJECT_POSITION)
time.sleep(1.0)
print("Eject: OK")
print(f"Tornant a OPEN_POSITION...")
servo_move_to(OPEN_POSITION)
time.sleep(1.0)
print("Torna a open: OK")
print(f"Provant posició mínima ({MIN_SERVO_US} µs)...")
servo_move_to(MIN_SERVO_US)
time.sleep(1.0)
print(f"Tornant a OPEN_POSITION...")
servo_move_to(OPEN_POSITION)
time.sleep(1.0)
_teardown(pi)
print("Test servo completat.\n")
# ==================
# TEST 2 — Sensor de color TCS34725
# ==================
def test_color_sensor():
"""
Llegeix el color 15 vegades cada segon.
Posa davant del sensor els blocs de colors per verificar que els reconeix.
"""
print("=== TEST SENSOR DE COLOR TCS34725 ===")
pi = _setup()
print("Llegint color durant 15 segons (posa els blocs davant del sensor)...")
for i in range(15):
color_id = read_block_color()
name = _COLOR_NAMES.get(color_id, "Desconegut")
print(f" Lectura {i+1:2d}: {name}")
time.sleep(1.0)
_teardown(pi)
print("Test sensor de color completat.\n")
# ==================
# TEST 3 — Calibració del sensor de color (valors RGB crus)
# ==================
def test_color_raw():
"""
Mostra valors RGB crus i la classificació actual durant 30 segons.
Útil per calibrar la taula _COLORS a blocks.py.
Format: R=xxx G=xxx B=xxx → classificat com 'XX' (diff=xx)
Si el diff és gran (>10), els valors de referència necessiten ajust.
"""
print("=== TEST COLOR RAW (calibració) ===")
pi = _setup()
print("Llegint valors RGB crus durant 30 segons (posa cada bloc davant del sensor)...")
print(f" {'R':>5} {'G':>5} {'B':>5} classificat diff")
for i in range(30):
r, g, b = read_color_raw()
best_name = "??"
best_diff = 9999
for ref in _COLORS:
diff = abs(r - ref["r"]) + abs(g - ref["g"]) + abs(b - ref["b"])
if diff < best_diff:
best_diff = diff
best_name = ref["name"]
print(f" R={r:3d} G={g:3d} B={b:3d}{best_name} (diff={best_diff})")
time.sleep(1.0)
_teardown(pi)
print("Test color raw completat.\n")
# ==================
# Execució
# ==================
if __name__ == "__main__":
# Descomenta el test que vols executar:
test_servo()
# test_color_sensor()
# test_color_raw() # Per calibrar la taula de colors

156
raspi/tests/test_eyes.py Normal file
View File

@@ -0,0 +1,156 @@
"""
test_eyes.py — Tests individuals del mòdul eyes.py.
Executa des del directori Rasp/: python tests/test_eyes.py
Descomenta la funció que vols provar al final del fitxer.
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import time
import pigpio
from eyes import (
eyes_setup, eyes_cleanup,
eyes_turn_on, eyes_turn_off,
eyes_gesture_mode_on, eyes_gesture_mode_off, eyes_listening,
EYES_OPEN, EYES_FW, EYES_DOWN, EYES_GESTURE,
WHITE, RED, GREEN, BLUE, YELLOW, ORANGE, PURPLE, CYAN, BLACK,
)
def _setup():
pi = pigpio.pi()
if not pi.connected:
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
sys.exit(1)
eyes_setup(pi)
return pi
def _teardown(pi):
eyes_cleanup()
pi.stop()
# ==================
# TEST 1 — Formes i colors bàsics
# ==================
def test_shapes():
"""
Mostra totes les formes existents en colors diferents.
Hauries de veure les formes als ulls LED del robot.
"""
print("=== TEST FORMES I COLORS ===")
pi = _setup()
print("EYES_OPEN en blanc...")
eyes_turn_on(EYES_OPEN, WHITE)
time.sleep(2.0)
print("EYES_FW en vermell...")
eyes_turn_on(EYES_FW, RED)
time.sleep(2.0)
print("EYES_DOWN en blau...")
eyes_turn_on(EYES_DOWN, BLUE)
time.sleep(2.0)
print("EYES_OPEN en verd...")
eyes_turn_on(EYES_OPEN, GREEN)
time.sleep(2.0)
print("Apagant...")
eyes_turn_off()
time.sleep(1.0)
_teardown(pi)
print("Test formes completat.\n")
# ==================
# TEST 2 — Animació de repeat i direcció
# ==================
def test_animation():
"""
Prova l'animació amb repeat i les dues direccions.
Hauries de veure els LEDs encenent-se un per un en ordre normal i invers.
"""
print("=== TEST ANIMACIÓ ===")
pi = _setup()
print("EYES_OPEN groc, repeat=2, endavant...")
eyes_turn_on(EYES_OPEN, YELLOW, repeat=2, forward=True)
time.sleep(1.0)
print("EYES_FW taronja, repeat=2, enrere...")
eyes_turn_on(EYES_FW, ORANGE, repeat=2, forward=False)
time.sleep(1.0)
eyes_turn_off()
_teardown(pi)
print("Test animació completat.\n")
# ==================
# TEST 3 — Animacions mode gestos (TFG)
# ==================
def test_gesture_animations():
"""
Prova les animacions específiques del mode gestos.
Hauries de veure: doble parpelleig cian, tornada a blanc, marc cian.
"""
print("=== TEST ANIMACIONS MODE GESTOS ===")
pi = _setup()
print("Activant mode gestos (doble parpelleig cian)...")
eyes_gesture_mode_on()
time.sleep(2.0)
print("Escoltant gest (marc cian)...")
eyes_listening()
time.sleep(2.0)
print("Desactivant mode gestos (torna a blanc)...")
eyes_gesture_mode_off()
time.sleep(2.0)
eyes_turn_off()
_teardown(pi)
print("Test animacions gestos completat.\n")
# ==================
# TEST 4 — Parpelleig (breathing)
# ==================
def test_breathing():
"""
Verifica que el thread de parpelleig funciona correctament.
Hauries de veure la brillantor dels LEDs pujant i baixant suaument.
"""
print("=== TEST PARPELLEIG (BREATHING) ===")
pi = _setup()
print("EYES_OPEN blanc — observa el parpelleig durant 10 segons...")
eyes_turn_on(EYES_OPEN, WHITE)
time.sleep(10.0)
eyes_turn_off()
_teardown(pi)
print("Test parpelleig completat.\n")
# ==================
# Execució
# ==================
if __name__ == "__main__":
# Descomenta el test que vols executar:
test_shapes()
# test_animation()
# test_gesture_animations()
# test_breathing()

View File

@@ -0,0 +1,94 @@
"""
test_gesture.py — Tests del mòdul gesture.py (sensor PAJ7620U2).
Executa des del directori Rasp/: python tests/test_gesture.py
Descomenta la funció que vols provar al final del fitxer.
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import time
from gesture import (
gesture_setup, gesture_cleanup,
read_gesture,
GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT,
GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE,
)
_GESTURE_NAMES = {
GS_NONE: "Cap (GS_NONE)",
GS_FORWARD: "Endavant (GS_FORWARD)",
GS_LEFT: "Esquerra (GS_LEFT)",
GS_RIGHT: "Dreta (GS_RIGHT)",
GS_UP: "Amunt (GS_UP)",
GS_DOWN: "Avall (GS_DOWN)",
GS_CLOCKWISE: "Horari (GS_CLOCKWISE)",
GS_ANTICLOCKWISE: "Antihorari (GS_ANTICLOCKWISE)",
GS_WAVE: "Wave (GS_WAVE)",
}
# ==================
# TEST 1 — Connexió i inicialització
# ==================
def test_connection():
"""
Comprova que el sensor PAJ7620U2 és accessible via I2C.
Ha de mostrar 'Gesture sensor init OK' sense errors.
"""
print("=== TEST CONNEXIÓ PAJ7620U2 ===")
gesture_setup()
time.sleep(0.5)
gesture_cleanup()
print("Test connexió completat.\n")
# ==================
# TEST 2 — Lectura de gestos
# ==================
def test_read_gestures():
"""
Llegeix gestos durant 30 segons i els mostra per pantalla.
Fes gestos davant del sensor per verificar que els detecta correctament:
- Mà cap endavant/enrere → GS_FORWARD
- Mà cap a l'esquerra → GS_LEFT
- Mà cap a la dreta → GS_RIGHT
- Mà cap amunt → GS_UP
- Mà cap avall → GS_DOWN
- Rotació horària → GS_CLOCKWISE
- Rotació antihorària → GS_ANTICLOCKWISE
- Sacsejada (wave) → GS_WAVE
"""
print("=== TEST LECTURA GESTOS PAJ7620U2 ===")
print("Fes gestos davant del sensor durant 30 segons...")
gesture_setup()
time.sleep(0.5)
inici = time.time()
while time.time() - inici < 30:
gest = read_gesture()
if gest != GS_NONE:
nom = _GESTURE_NAMES.get(gest, f"Desconegut ({gest})")
print(f" Gest detectat: {nom}")
time.sleep(0.05)
gesture_cleanup()
print("Test lectura gestos completat.\n")
# ==================
# Execució
# ==================
if __name__ == "__main__":
# Descomenta el test que vols executar:
test_connection()
# test_read_gestures()

238
raspi/tests/test_motion.py Normal file
View File

@@ -0,0 +1,238 @@
"""
test_motion.py — Tests individuals del mòdul motion.py.
Executa des del directori Rasp/: python tests/test_motion.py
Descomenta la funció que vols provar al final del fitxer.
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import time
import pigpio
import motion
from motion import (
motion_setup, motion_setup_steppers, motion_setup_sensors, motion_cleanup,
enable_wheels, enable_arms, enable_syringe,
arms_home, syringe_home,
distance_to_object,
ON, OFF, CW, CCW,
)
def _pi_connect():
pi = pigpio.pi()
if not pi.connected:
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
sys.exit(1)
return pi
def _setup_motors():
"""Setup mínim per a tests de motors (sense sensors I2C)."""
pi = _pi_connect()
motion_setup_steppers(pi)
return pi
def _setup_sensors():
"""Setup mínim per a tests de sensors I2C (sense steppers)."""
pi = _pi_connect()
motion_setup_sensors(pi)
return pi
def _teardown_motors(pi):
motion_cleanup()
pi.stop()
def _teardown_sensors(pi):
pi.stop()
# ==================
# TEST 1 — Motors pas a pas (rodes)
# ==================
def test_motor_unic():
"""
Prova únicament la roda DRETA: 200 passos endavant i enrere.
Primer test a fer — verifica que un sol motor funciona correctament.
Hauries de veure la roda girar ~1/5 de volta (reducció 1:5).
"""
print("=== TEST MOTOR ÚNIC (RODA DRETA) ===")
pi = _setup_motors()
PASSOS = 200
print(f"Movent roda DRETA {PASSOS} passos endavant...")
enable_wheels(ON)
motion.wheel_R.move_to(PASSOS)
while motion.wheel_R.is_running():
time.sleep(0.05)
print("Roda dreta endavant: OK")
time.sleep(0.5)
print(f"Movent roda DRETA {PASSOS} passos enrere...")
motion.wheel_R.move_to(0)
while motion.wheel_R.is_running():
time.sleep(0.05)
print("Roda dreta enrere: OK")
enable_wheels(OFF)
_teardown_motors(pi)
print("Test motor únic completat.\n")
def test_motors():
"""
Prova les dues rodes: avança 200 passos i torna enrere.
Executa test_motor_unic() primer per verificar que un motor funciona.
Hauries de veure cada roda girar ~1/5 de volta (reducció 1:5).
"""
print("=== TEST MOTORS (RODES) ===")
pi = _setup_motors()
PASSOS = 200
print(f"Movent roda DRETA {PASSOS} passos endavant...")
enable_wheels(ON)
motion.wheel_R.move_to(PASSOS)
while motion.wheel_R.is_running():
time.sleep(0.05)
print("Roda dreta: OK")
time.sleep(0.5)
print(f"Movent roda DRETA {PASSOS} passos enrere...")
motion.wheel_R.move_to(0)
while motion.wheel_R.is_running():
time.sleep(0.05)
print("Roda dreta enrere: OK")
time.sleep(0.5)
print(f"Movent roda ESQUERRA {PASSOS} passos endavant...")
motion.wheel_L.move_to(PASSOS)
while motion.wheel_L.is_running():
time.sleep(0.05)
print("Roda esquerra: OK")
time.sleep(0.5)
print(f"Movent roda ESQUERRA {PASSOS} passos enrere...")
motion.wheel_L.move_to(0)
while motion.wheel_L.is_running():
time.sleep(0.05)
print("Roda esquerra enrere: OK")
enable_wheels(OFF)
_teardown_motors(pi)
print("Test motors completat.\n")
# ==================
# TEST 2 — Homing (finals de carrera)
# ==================
def test_homing_brac():
"""
Executa el homing únicament dels BRAÇOS.
Primer test de homing — verifica que un sol conjunt de finals de carrera funciona.
ATENCIÓ: assegura't que els braços tinguin espai per moure's.
"""
print("=== TEST HOMING BRAÇOS ===")
pi = _setup_motors()
print("Iniciant homing dels BRAÇOS...")
arms_home()
print("Homing braços: OK")
_teardown_motors(pi)
print("Test homing braços completat.\n")
def test_homing():
"""
Executa el homing complet: braços i xeringa.
Executa test_homing_brac() primer per verificar els finals de carrera dels braços.
ATENCIÓ: assegura't que els braços i la xeringa tinguin espai per moure's.
"""
print("=== TEST HOMING COMPLET ===")
pi = _setup_motors()
print("Iniciant homing dels BRAÇOS...")
arms_home()
print("Homing braços: OK")
time.sleep(1.0)
print("Iniciant homing de la XERINGA...")
syringe_home()
print("Homing xeringa: OK")
_teardown_motors(pi)
print("Test homing completat.\n")
# ==================
# TEST 3 — Sensor de distància VL53L0X
# ==================
def test_distance_sensor():
"""
Llegeix la distància 10 vegades cada 500ms.
Posa la mà davant del sensor per verificar que canvia el valor.
"""
print("=== TEST SENSOR DISTÀNCIA VL53L0X ===")
pi = _setup_sensors()
print("Llegint distància durant 5 segons (posa la mà davant del sensor)...")
for i in range(10):
dist = distance_to_object()
if dist == 65535:
print(f" Lectura {i+1:2d}: fora de rang")
else:
print(f" Lectura {i+1:2d}: {dist} mm")
time.sleep(0.5)
_teardown_sensors(pi)
print("Test sensor distància completat.\n")
# ==================
# TEST 4 — Sensors de línia ADS1115
# ==================
def test_line_sensors():
"""
Llegeix els dos sensors de línia 10 vegades cada 500ms.
Posa el sensor sobre superfícies de diferent color per veure la variació.
Valor alt (~9700+) = negre. Valor baix = blanc/clar.
"""
print("=== TEST SENSORS DE LÍNIA ADS1115 ===")
pi = _setup_sensors()
print("Llegint sensors de línia durant 5 segons...")
print(" (posa els sensors sobre blanc i negre per veure la diferència)")
for i in range(10):
r = motion._chan_r.value
l = motion._chan_l.value
print(f" Lectura {i+1:2d}: DRETA={r:5d} ESQUERRA={l:5d} error={r-l:+6d}")
time.sleep(0.5)
_teardown_sensors(pi)
print("Test sensors de línia completat.\n")
# ==================
# Execució
# ==================
if __name__ == "__main__":
# Descomenta el test que vols executar:
test_motor_unic() # Primer: prova un sol motor
# test_motors() # Segon: prova les dues rodes
# test_homing_brac() # Primer homing: només els braços
# test_homing() # Homing complet: braços i xeringa
# test_distance_sensor()
# test_line_sensors()

View File

@@ -0,0 +1,53 @@
"""
test_simple_motor.py — Test mínim de la roda dreta per diagnosticar problemes.
Executa des del directori Rasp/tests/: python3 test_simple_motor.py
Diferències respecte a test_motion.py:
- Usa pi.write() directe en lloc de gpio_trigger()
- Bucle bloquejant amb time.sleep() en lloc de thread
- Sense acceleració, velocitat constant
"""
import pigpio
import time
STEP = 25 # STEP_R_W
DIR = 23 # DIR_R_W
EN = 6 # EN_W (actiu LOW)
pi = pigpio.pi()
if not pi.connected:
print("ERROR: pigpiod no està en marxa")
exit(1)
pi.set_mode(STEP, pigpio.OUTPUT)
pi.set_mode(DIR, pigpio.OUTPUT)
pi.set_mode(EN, pigpio.OUTPUT)
pi.write(EN, 0) # Activa el driver (LOW = ON)
pi.write(DIR, 1) # Endavant
# 500 passos/s → període = 1/500 = 2ms → mig període = 1ms
DELAY = 0.001
print("Movent 200 passos endavant...")
for _ in range(200):
pi.write(STEP, 1)
time.sleep(DELAY)
pi.write(STEP, 0)
time.sleep(DELAY)
print("Fet. Esperant 1s...")
time.sleep(1)
pi.write(DIR, 0) # Enrere
print("Movent 200 passos enrere...")
for _ in range(200):
pi.write(STEP, 1)
time.sleep(DELAY)
pi.write(STEP, 0)
time.sleep(DELAY)
print("Fet.")
pi.write(EN, 1) # Desactiva el driver
pi.stop()