TTs whisper
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
345
AGENTS.md
Normal file
345
AGENTS.md
Normal 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
|
||||||
79
README.md
79
README.md
@@ -5,79 +5,18 @@ Normes del repositori:
|
|||||||
- S’ha de treballar en branques pròpies (opcional)
|
- S’ha de treballar en branques pròpies (opcional)
|
||||||
- No es pot modificar la carpeta d’altres zones
|
- No es pot modificar la carpeta d’altres 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
BIN
UPCLogo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
|
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
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 {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -12,8 +13,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
|
||||||
import { loadRecorderSettings } from "@/lib/recorder-settings";
|
import { loadRecorderSettings } from "@/lib/recorder-settings";
|
||||||
|
import { getStrings, type Locale, t } from "@/lib/translations";
|
||||||
|
|
||||||
function formatDuration(durationMs: number) {
|
function formatDuration(durationMs: number) {
|
||||||
const totalSeconds = Math.floor(durationMs / 1000);
|
const totalSeconds = Math.floor(durationMs / 1000);
|
||||||
@@ -50,14 +51,16 @@ export default function RecorderScreen() {
|
|||||||
const [backendUrl, setBackendUrl] = useState("");
|
const [backendUrl, setBackendUrl] = useState("");
|
||||||
const [authToken, setAuthToken] = useState("");
|
const [authToken, setAuthToken] = useState("");
|
||||||
const [fieldName, setFieldName] = useState("file");
|
const [fieldName, setFieldName] = useState("file");
|
||||||
|
const [locale, setLocale] = useState<Locale>("ca");
|
||||||
|
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||||
const [recording, setRecording] = useState<Audio.Recording | null>(null);
|
const [recording, setRecording] = useState<Audio.Recording | null>(null);
|
||||||
const [recordingUri, setRecordingUri] = useState<string | null>(null);
|
const [recordingUri, setRecordingUri] = useState<string | null>(null);
|
||||||
const [recordingMs, setRecordingMs] = useState(0);
|
const [recordingMs, setRecordingMs] = useState(0);
|
||||||
const [statusMessage, setStatusMessage] = useState(
|
const [statusMessage, setStatusMessage] = useState("");
|
||||||
"Ready to record and send audio.",
|
|
||||||
);
|
|
||||||
const [responsePreview, setResponsePreview] = useState("");
|
const [responsePreview, setResponsePreview] = useState("");
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isHolding, setIsHolding] = useState(false);
|
||||||
|
const recordingRef = useRef<Audio.Recording | null>(null);
|
||||||
|
|
||||||
const refreshSettings = useCallback(() => {
|
const refreshSettings = useCallback(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -73,9 +76,11 @@ export default function RecorderScreen() {
|
|||||||
setBackendUrl(settings.backendUrl);
|
setBackendUrl(settings.backendUrl);
|
||||||
setAuthToken(settings.authToken);
|
setAuthToken(settings.authToken);
|
||||||
setFieldName(settings.fieldName);
|
setFieldName(settings.fieldName);
|
||||||
|
setLocale(settings.language);
|
||||||
|
setStrings(getStrings(settings.language));
|
||||||
} catch {
|
} catch {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setStatusMessage("Could not load saved backend settings.");
|
setStatusMessage(strings.loadError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,10 +128,10 @@ export default function RecorderScreen() {
|
|||||||
const permission = await Audio.requestPermissionsAsync();
|
const permission = await Audio.requestPermissionsAsync();
|
||||||
|
|
||||||
if (!permission.granted) {
|
if (!permission.granted) {
|
||||||
setStatusMessage("Microphone permission was denied.");
|
setStatusMessage(strings.micPermissionDenied);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Microphone access required",
|
strings.micAccessRequiredTitle,
|
||||||
"Enable microphone access to record audio.",
|
strings.micAccessRequiredMsg,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -144,26 +149,28 @@ export default function RecorderScreen() {
|
|||||||
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
recordingRef.current = result.recording;
|
||||||
setRecording(result.recording);
|
setRecording(result.recording);
|
||||||
setRecordingMs(0);
|
setRecordingMs(0);
|
||||||
setStatusMessage("Recording in progress.");
|
setStatusMessage(strings.recording);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage("Recording could not be started.");
|
setStatusMessage(strings.couldNotStartRecording);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Recording failed",
|
strings.recordingFailedTitle,
|
||||||
error instanceof Error ? error.message : "Unknown recording error.",
|
error instanceof Error ? error.message : "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecording() {
|
async function stopRecordingAndUpload() {
|
||||||
if (!recording) {
|
if (!recordingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activeRecording = recording;
|
const activeRecording = recordingRef.current;
|
||||||
const currentStatus = await activeRecording.getStatusAsync();
|
const currentStatus = await activeRecording.getStatusAsync();
|
||||||
|
const durationMillis = currentStatus.durationMillis ?? 0;
|
||||||
|
|
||||||
await activeRecording.stopAndUnloadAsync();
|
await activeRecording.stopAndUnloadAsync();
|
||||||
await Audio.setAudioModeAsync({
|
await Audio.setAudioModeAsync({
|
||||||
@@ -172,25 +179,86 @@ export default function RecorderScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const uri = activeRecording.getURI();
|
const uri = activeRecording.getURI();
|
||||||
|
recordingRef.current = null;
|
||||||
setRecording(null);
|
setRecording(null);
|
||||||
setRecordingMs(currentStatus.durationMillis ?? recordingMs);
|
setRecordingMs(durationMillis);
|
||||||
setRecordingUri(uri);
|
|
||||||
setStatusMessage(
|
|
||||||
uri ? "Recording finished. Preparing to send voice message." : "Recording finished.",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (uri && backendUrl.trim()) {
|
if (!uri) {
|
||||||
await uploadRecording(uri);
|
setStatusMessage(strings.readyToRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecordingUri(uri);
|
||||||
|
setStatusMessage(strings.finishedUpload);
|
||||||
|
|
||||||
|
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
||||||
|
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
||||||
|
? trimmedUrl
|
||||||
|
: `${trimmedUrl}/audio/upload`;
|
||||||
|
if (uploadUrl) {
|
||||||
|
try {
|
||||||
|
const mimeType = buildMimeType(uri);
|
||||||
|
const extension = buildFileExtension(uri);
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append(fieldName.trim() || "file", {
|
||||||
|
name: `recording-${Date.now()}.${extension}`,
|
||||||
|
type: mimeType,
|
||||||
|
uri: uri,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (authToken.trim()) {
|
||||||
|
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
setResponsePreview(responseText.slice(0, 400));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status}. ${responseText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage(strings.voiceMessageSent);
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage(strings.uploadFailed);
|
||||||
|
Alert.alert(
|
||||||
|
strings.uploadFailed,
|
||||||
|
error instanceof Error ? error.message : "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStatusMessage(strings.noBackendUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage("Recording could not be stopped cleanly.");
|
recordingRef.current = null;
|
||||||
|
setRecording(null);
|
||||||
|
setStatusMessage(strings.stopFailedTitle);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Stop failed",
|
strings.stopFailedTitle,
|
||||||
error instanceof Error ? error.message : "Unknown stop error.",
|
error instanceof Error ? error.message : "",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePressIn() {
|
||||||
|
if (isUploading) return;
|
||||||
|
setIsHolding(true);
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePressOut() {
|
||||||
|
if (!isHolding) return;
|
||||||
|
setIsHolding(false);
|
||||||
|
await stopRecordingAndUpload();
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadRecording(uriOverride?: string) {
|
async function uploadRecording(uriOverride?: string) {
|
||||||
const targetUri = uriOverride ?? recordingUri;
|
const targetUri = uriOverride ?? recordingUri;
|
||||||
|
|
||||||
@@ -198,16 +266,19 @@ export default function RecorderScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedUrl = backendUrl.trim();
|
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
||||||
|
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
||||||
|
? trimmedUrl
|
||||||
|
: `${trimmedUrl}/audio/upload`;
|
||||||
|
|
||||||
if (!trimmedUrl) {
|
if (!uploadUrl) {
|
||||||
Alert.alert("Missing backend URL", "Enter the backend endpoint first.");
|
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setStatusMessage("Uploading recording.");
|
setStatusMessage(strings.uploadingRecording);
|
||||||
setResponsePreview("");
|
setResponsePreview("");
|
||||||
|
|
||||||
const mimeType = buildMimeType(targetUri);
|
const mimeType = buildMimeType(targetUri);
|
||||||
@@ -226,7 +297,7 @@ export default function RecorderScreen() {
|
|||||||
headers.Authorization = `Bearer ${authToken.trim()}`;
|
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(trimmedUrl, {
|
const response = await fetch(uploadUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -236,23 +307,30 @@ export default function RecorderScreen() {
|
|||||||
setResponsePreview(responseText.slice(0, 400));
|
setResponsePreview(responseText.slice(0, 400));
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Upload failed with ${response.status}. ${responseText}`);
|
throw new Error(`${response.status}. ${responseText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatusMessage("Upload complete.");
|
setStatusMessage(strings.uploadComplete);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage("Upload failed.");
|
setStatusMessage(strings.uploadFailed);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Upload failed",
|
strings.uploadFailed,
|
||||||
error instanceof Error ? error.message : "Unknown upload error.",
|
error instanceof Error ? error.message : "",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const releaseLabel = t("releaseToStop", locale);
|
||||||
|
const holdLabel = t("holdToRecord", locale);
|
||||||
|
const openSettingsLabel = t("openSettingsHint", locale);
|
||||||
|
const appTitleLabel = t("appTitle", locale);
|
||||||
|
const recorderTitleLabel = t("recorderTitle", locale);
|
||||||
|
const serverResponseLabel = t("serverResponse", locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.keyboardAvoidingView}
|
style={styles.keyboardAvoidingView}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
@@ -265,59 +343,88 @@ export default function RecorderScreen() {
|
|||||||
<View style={styles.hero}>
|
<View style={styles.hero}>
|
||||||
<View style={styles.heroTopRow}>
|
<View style={styles.heroTopRow}>
|
||||||
<View style={styles.heroBadge}>
|
<View style={styles.heroBadge}>
|
||||||
<Text style={styles.heroBadgeText}>Assistant Voice</Text>
|
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={() => router.push("/settings")} style={styles.settingsLink}>
|
<Pressable
|
||||||
<Text style={styles.settingsLinkText}>Settings</Text>
|
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>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
Record a voice message and send it to your backend.
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.panel}>
|
<View style={styles.panel}>
|
||||||
<Text style={styles.meterValueCentered}>
|
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
||||||
{formatDuration(recordingMs)}
|
{formatDuration(recordingMs)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
onPress={recording ? stopRecording : startRecording}
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
style={[
|
style={[
|
||||||
styles.micButton,
|
styles.micButton,
|
||||||
recording ? styles.stopButton : styles.recordButton,
|
isHolding ? styles.holdingButton : styles.idleButton,
|
||||||
isUploading && styles.buttonDisabled,
|
isUploading && styles.buttonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<ActivityIndicator color="#fff6f3" size="large" />
|
<ActivityIndicator color="#fff6f3" size="large" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.micButtonText}>
|
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||||
{recording ? "Stop" : "Record"}
|
<Path
|
||||||
</Text>
|
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>
|
</Pressable>
|
||||||
|
|
||||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
|
||||||
<Text style={styles.helperText}>
|
<Text style={styles.helperText}>
|
||||||
{backendUrl.trim()
|
{isHolding
|
||||||
? recording
|
? releaseLabel
|
||||||
? "Tap again when you finish speaking."
|
: backendUrl.trim()
|
||||||
: "Tap the button to start a new voice message."
|
? holdLabel
|
||||||
: "Open settings to add your backend URL before sending voice messages."}
|
: openSettingsLabel}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{responsePreview ? (
|
{responsePreview ? (
|
||||||
<View style={styles.responseBox}>
|
<View style={styles.responseBox}>
|
||||||
<Text style={styles.responseLabel}>Server response</Text>
|
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
|
||||||
<Text style={styles.responseText}>{responsePreview}</Text>
|
<Text style={styles.responseText}>{responsePreview}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,17 +440,17 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 32,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 32,
|
|
||||||
paddingTop: 8,
|
|
||||||
gap: 18,
|
gap: 18,
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
backgroundColor: "#13304a",
|
backgroundColor: "transparent",
|
||||||
borderRadius: 28,
|
|
||||||
paddingHorizontal: 22,
|
paddingHorizontal: 22,
|
||||||
paddingVertical: 24,
|
paddingTop: 40,
|
||||||
gap: 12,
|
|
||||||
},
|
},
|
||||||
heroTopRow: {
|
heroTopRow: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -351,7 +458,6 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
heroBadge: {
|
heroBadge: {
|
||||||
alignSelf: "flex-start",
|
|
||||||
backgroundColor: "#f2b15d",
|
backgroundColor: "#f2b15d",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
@@ -364,22 +470,14 @@ const styles = StyleSheet.create({
|
|||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
},
|
},
|
||||||
settingsLink: {
|
settingsCog: {
|
||||||
borderColor: "#58718d",
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
borderWidth: 1,
|
backgroundColor: "#13304a",
|
||||||
paddingHorizontal: 12,
|
marginLeft: 12,
|
||||||
paddingVertical: 7,
|
|
||||||
},
|
|
||||||
settingsLinkText: {
|
|
||||||
color: "#d3deea",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
color: "#d3deea",
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
},
|
||||||
panel: {
|
panel: {
|
||||||
backgroundColor: "#fffaf1",
|
backgroundColor: "#fffaf1",
|
||||||
@@ -388,6 +486,8 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
padding: 18,
|
padding: 18,
|
||||||
|
alignSelf: "center",
|
||||||
|
maxWidth: 340,
|
||||||
},
|
},
|
||||||
meterValueCentered: {
|
meterValueCentered: {
|
||||||
color: "#d04f2d",
|
color: "#d04f2d",
|
||||||
@@ -404,17 +504,21 @@ const styles = StyleSheet.create({
|
|||||||
width: 164,
|
width: 164,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
},
|
},
|
||||||
recordButton: {
|
idleButton: {
|
||||||
backgroundColor: "#d04f2d",
|
backgroundColor: "#13304a",
|
||||||
},
|
},
|
||||||
stopButton: {
|
holdingButton: {
|
||||||
backgroundColor: "#8c1c13",
|
backgroundColor: "#d04f2d",
|
||||||
|
transform: [{ scale: 1.08 }],
|
||||||
},
|
},
|
||||||
micButtonText: {
|
micButtonText: {
|
||||||
color: "#fff6f3",
|
color: "#fff6f3",
|
||||||
fontSize: 24,
|
fontSize: 20,
|
||||||
fontWeight: "800",
|
fontWeight: "800",
|
||||||
},
|
},
|
||||||
|
recordingLabel: {
|
||||||
|
fontSize: 18,
|
||||||
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.45,
|
opacity: 0.45,
|
||||||
},
|
},
|
||||||
@@ -422,11 +526,13 @@ const styles = StyleSheet.create({
|
|||||||
color: "#1f2d3d",
|
color: "#1f2d3d",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
lineHeight: 21,
|
lineHeight: 21,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
helperText: {
|
helperText: {
|
||||||
color: "#665f54",
|
color: "#665f54",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
responseBox: {
|
responseBox: {
|
||||||
backgroundColor: "#f7f0e0",
|
backgroundColor: "#f7f0e0",
|
||||||
@@ -440,6 +546,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
responseText: {
|
responseText: {
|
||||||
color: "#36475a",
|
color: "#36475a",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Picker } from "@react-native-picker/picker";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -16,11 +17,20 @@ import {
|
|||||||
loadRecorderSettings,
|
loadRecorderSettings,
|
||||||
saveRecorderSettings,
|
saveRecorderSettings,
|
||||||
} from "@/lib/recorder-settings";
|
} 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() {
|
export default function SettingsScreen() {
|
||||||
const [backendUrl, setBackendUrl] = useState("");
|
const [backendUrl, setBackendUrl] = useState("");
|
||||||
const [authToken, setAuthToken] = useState("");
|
const [authToken, setAuthToken] = useState("");
|
||||||
const [fieldName, setFieldName] = useState("file");
|
const [fieldName, setFieldName] = useState("file");
|
||||||
|
const [language, setLanguage] = useState<Locale>("ca");
|
||||||
|
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -36,9 +46,11 @@ export default function SettingsScreen() {
|
|||||||
setBackendUrl(settings.backendUrl);
|
setBackendUrl(settings.backendUrl);
|
||||||
setAuthToken(settings.authToken);
|
setAuthToken(settings.authToken);
|
||||||
setFieldName(settings.fieldName);
|
setFieldName(settings.fieldName);
|
||||||
|
setLanguage(settings.language);
|
||||||
|
setStrings(getStrings(settings.language));
|
||||||
} catch {
|
} catch {
|
||||||
if (isMounted) {
|
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() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
await saveRecorderSettings({
|
await saveRecorderSettings({
|
||||||
authToken,
|
authToken,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
fieldName,
|
fieldName,
|
||||||
|
language,
|
||||||
});
|
});
|
||||||
|
setStrings(getStrings(language));
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -76,54 +97,73 @@ export default function SettingsScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<Pressable onPress={() => router.back()} style={styles.navButton}>
|
<Pressable onPress={() => router.back()} style={styles.navButton}>
|
||||||
<Text style={styles.navButtonText}>Back</Text>
|
<Text style={styles.navButtonText}>{langStrings.back}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={styles.title}>Settings</Text>
|
<Text style={styles.title}>{langStrings.settingsTitle}</Text>
|
||||||
<Pressable onPress={handleSave} style={styles.navButton}>
|
<Pressable onPress={handleSave} style={styles.navButton}>
|
||||||
<Text style={styles.navButtonText}>Save</Text>
|
<Text style={styles.navButtonText}>{langStrings.save}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.panel}>
|
<View style={styles.panel}>
|
||||||
<Text style={styles.label}>Backend URL</Text>
|
<Text style={styles.label}>{langStrings.backendUrl}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
onChangeText={setBackendUrl}
|
onChangeText={setBackendUrl}
|
||||||
placeholder="https://api.example.com/upload"
|
placeholder={langStrings.urlPlaceholder}
|
||||||
placeholderTextColor="#8f8a7c"
|
placeholderTextColor="#8f8a7c"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={backendUrl}
|
value={backendUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Bearer token</Text>
|
<Text style={styles.label}>{langStrings.bearerToken}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
onChangeText={setAuthToken}
|
onChangeText={setAuthToken}
|
||||||
placeholder="Optional"
|
placeholder={langStrings.tokenOptional}
|
||||||
placeholderTextColor="#8f8a7c"
|
placeholderTextColor="#8f8a7c"
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={authToken}
|
value={authToken}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Form field name</Text>
|
<Text style={styles.label}>{langStrings.formFieldName}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
onChangeText={setFieldName}
|
onChangeText={setFieldName}
|
||||||
placeholder="file"
|
placeholder={langStrings.fieldNamePlaceholder}
|
||||||
placeholderTextColor="#8f8a7c"
|
placeholderTextColor="#8f8a7c"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={fieldName}
|
value={fieldName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.helperText}>
|
<Text style={styles.helperText}>
|
||||||
The recording is uploaded as multipart field `{fieldName.trim() || "file"}`.
|
{t("helperText", language, fieldName.trim() || "file")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -199,4 +239,14 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
pickerWrapper: {
|
||||||
|
backgroundColor: "#f7f0e0",
|
||||||
|
borderColor: "#d9ccb5",
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
picker: {
|
||||||
|
height: 50,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
import type { Locale } from "./translations";
|
||||||
|
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
authToken: "recorder.authToken",
|
authToken: "recorder.authToken",
|
||||||
backendUrl: "recorder.backendUrl",
|
backendUrl: "recorder.backendUrl",
|
||||||
fieldName: "recorder.fieldName",
|
fieldName: "recorder.fieldName",
|
||||||
|
language: "recorder.language",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecorderSettings = {
|
export type RecorderSettings = {
|
||||||
authToken: string;
|
authToken: string;
|
||||||
backendUrl: string;
|
backendUrl: string;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
|
language: Locale;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||||
@@ -17,6 +21,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
|||||||
STORAGE_KEYS.backendUrl,
|
STORAGE_KEYS.backendUrl,
|
||||||
STORAGE_KEYS.authToken,
|
STORAGE_KEYS.authToken,
|
||||||
STORAGE_KEYS.fieldName,
|
STORAGE_KEYS.fieldName,
|
||||||
|
STORAGE_KEYS.language,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const values = Object.fromEntries(entries);
|
const values = Object.fromEntries(entries);
|
||||||
@@ -25,6 +30,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
|||||||
authToken: values[STORAGE_KEYS.authToken] ?? "",
|
authToken: values[STORAGE_KEYS.authToken] ?? "",
|
||||||
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
|
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
|
||||||
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
|
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.backendUrl, settings.backendUrl],
|
||||||
[STORAGE_KEYS.authToken, settings.authToken],
|
[STORAGE_KEYS.authToken, settings.authToken],
|
||||||
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
|
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
|
||||||
|
[STORAGE_KEYS.language, settings.language],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
100
apk/lib/translations/index.ts
Normal file
100
apk/lib/translations/index.ts
Normal 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;
|
||||||
|
}
|
||||||
170
apk/package-lock.json
generated
170
apk/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-picker/picker": "^2.11.4",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.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"
|
"react-native-web": "~0.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2769,6 +2771,19 @@
|
|||||||
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
"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/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@@ -4538,6 +4553,12 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/bplist-creator": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||||
@@ -5061,6 +5082,56 @@
|
|||||||
"hyphenate-style-name": "^1.0.3"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -5277,6 +5348,61 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.4.7",
|
"version": "16.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||||
@@ -5346,6 +5472,18 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/env-editor": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||||
@@ -8736,6 +8874,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
@@ -9337,6 +9481,18 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/nullthrows": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||||
@@ -10243,6 +10399,20 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-picker/picker": "^2.11.4",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
@@ -20,8 +21,9 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
9
backend/.env.example
Normal file
9
backend/.env.example
Normal 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
6
backend/.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
__pycache__/
|
node_modules/
|
||||||
venv/
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|||||||
1919
backend/package-lock.json
generated
Normal file
1919
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
backend/quibot-audio-1781783002989.txt
Normal file
1
backend/quibot-audio-1781783002989.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Col·la, pítalo, la ola, ola.
|
||||||
1
backend/quibot-audio-1781783032108.txt
Normal file
1
backend/quibot-audio-1781783032108.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hola, què tal, hola, hola, hola, hola...
|
||||||
1
backend/quibot-audio-1781783047628.txt
Normal file
1
backend/quibot-audio-1781783047628.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hola, que tal, bon dia.
|
||||||
34
backend/src/config.ts
Normal file
34
backend/src/config.ts
Normal 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;
|
||||||
119
backend/src/controllers/audio.controller.ts
Normal file
119
backend/src/controllers/audio.controller.ts
Normal 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;
|
||||||
20
backend/src/controllers/command.controller.ts
Normal file
20
backend/src/controllers/command.controller.ts
Normal 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;
|
||||||
53
backend/src/controllers/motor.controller.ts
Normal file
53
backend/src/controllers/motor.controller.ts
Normal 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;
|
||||||
25
backend/src/controllers/settings.controller.ts
Normal file
25
backend/src/controllers/settings.controller.ts
Normal 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;
|
||||||
25
backend/src/index.ts
Normal file
25
backend/src/index.ts
Normal 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()}`);
|
||||||
|
});
|
||||||
14
backend/src/routes/router.ts
Normal file
14
backend/src/routes/router.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use('/motor', motorController);
|
||||||
|
router.use('/audio', audioController);
|
||||||
|
router.use('/commands', commandController);
|
||||||
|
router.use('/settings', settingsController);
|
||||||
|
|
||||||
|
export default router;
|
||||||
77
backend/src/services/raspi.service.ts
Normal file
77
backend/src/services/raspi.service.ts
Normal 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
20
backend/tsconfig.json
Normal 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
BIN
igualtat_h3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
logo-qui-bot-capcalera.png
Normal file
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
389
package-lock.json
generated
Normal 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
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vue-i18n": "^11.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
quibot-web/README.md
Normal file
67
quibot-web/README.md
Normal 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
|
||||||
File diff suppressed because it is too large
Load Diff
149
quibot-web/app/locales/ca.ts
Normal file
149
quibot-web/app/locales/ca.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
149
quibot-web/app/locales/en.ts
Normal file
149
quibot-web/app/locales/en.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
149
quibot-web/app/locales/es.ts
Normal file
149
quibot-web/app/locales/es.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
38
quibot-web/app/plugins/i18n.ts
Normal file
38
quibot-web/app/plugins/i18n.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
269
quibot-web/components/SettingsModal.vue
Normal file
269
quibot-web/components/SettingsModal.vue
Normal 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>
|
||||||
@@ -5,6 +5,10 @@ export default defineNuxtConfig({
|
|||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000',
|
quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000',
|
||||||
quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN',
|
quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN',
|
||||||
|
public: {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
supportedLocales: ['en', 'ca', 'es'],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
|||||||
2233
quibot-web/package-lock.json
generated
2233
quibot-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
|
"react-native-svg": "^15.15.5",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const direction = getRouterParam(event, 'direction')
|
const direction = getRouterParam(event, 'direction')
|
||||||
|
|
||||||
if (direction !== 'forward' && direction !== 'backwards') {
|
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',
|
method: 'POST',
|
||||||
query: {
|
query: {
|
||||||
token: config.quibotToken,
|
token: config.quibotToken,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
const baseUrl = getPiBaseUrl(event)
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
return await $fetch(`${config.quibotBaseUrl}/motor/stop`, {
|
return await $fetch(`${baseUrl}/motor/stop`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
query: {
|
query: {
|
||||||
token: config.quibotToken,
|
token: config.quibotToken,
|
||||||
|
|||||||
10
quibot-web/server/plugins/settings.ts
Normal file
10
quibot-web/server/plugins/settings.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
9
quibot-web/server/utils/pi-url.ts
Normal file
9
quibot-web/server/utils/pi-url.ts
Normal 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
2
raspi/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
venv/
|
||||||
160
raspi/blocks.py
Normal file
160
raspi/blocks.py
Normal 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 0–255, 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
273
raspi/eyes.py
Normal 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 (0–255)
|
||||||
|
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
238
raspi/gesture.py
Normal 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 0–7: 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
619
raspi/motion.py
Normal 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 0–26400 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), 0–32767."""
|
||||||
|
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
84
raspi/pins.py
Normal 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
286
raspi/quibot.py
Normal 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
147
raspi/tests/test_blocks.py
Normal 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
156
raspi/tests/test_eyes.py
Normal 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()
|
||||||
94
raspi/tests/test_gesture.py
Normal file
94
raspi/tests/test_gesture.py
Normal 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
238
raspi/tests/test_motion.py
Normal 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()
|
||||||
53
raspi/tests/test_simple_motor.py
Normal file
53
raspi/tests/test_simple_motor.py
Normal 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()
|
||||||
Reference in New Issue
Block a user