Compare commits
12 Commits
0e7fbbfdca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ac77a8b2 | |||
| 23235e8249 | |||
| 63903e6f7e | |||
| 432df63298 | |||
| 023d3c04b9 | |||
| f24cbd248d | |||
| bf13fdc33c | |||
| 8ae828fb6e | |||
| a16f31a331 | |||
| 76a56d1a42 | |||
| 5086743a11 | |||
| 9a23863320 |
@@ -2,7 +2,7 @@ name: Build APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -44,20 +44,28 @@ jobs:
|
||||
working-directory: ./apk/android
|
||||
run: |
|
||||
./gradlew assembleRelease
|
||||
- name: 📦 Zip APK
|
||||
working-directory: .
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp apk/android/app/build/outputs/apk/release/app-release.apk dist/
|
||||
zip -j dist/build.zip dist/app-release.apk
|
||||
- name: 📤 Upload APK Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: app-release-apk
|
||||
path: apk/android/app/build/outputs/apk/release/app-release.apk
|
||||
release:
|
||||
runs-on: docker
|
||||
needs: [build]
|
||||
steps:
|
||||
- name: Download Web Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: app-release-apk
|
||||
path: dist
|
||||
|
||||
- name: Create Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
working-directory: dist
|
||||
with:
|
||||
tag_name: latest
|
||||
name: Latest Build
|
||||
overwrite_files: true
|
||||
files: |
|
||||
- build.zip
|
||||
dist/app-release.apk
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA }}
|
||||
@@ -3,7 +3,7 @@ name: Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
359
AGENTS.md
Normal file
359
AGENTS.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# 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 two independent application layers communicating via HTTP JSON APIs.
|
||||
|
||||
```
|
||||
[quibot-web Nuxt SPA] ──HTTP──> [backend Express]
|
||||
│ │
|
||||
▼ ├──▶ Raspberry Pi (port 8000) — motor/audio endpoints
|
||||
[apk Expo RN app] ──HTTP──> ├──▶ LLM (llamacpp)
|
||||
└──▶ TTS (Piper)
|
||||
```
|
||||
|
||||
**Tech stack**: 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/
|
||||
├── backend/ # Express server (port 5000), proxy to Pi + LLM/TTS
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # Express entry: CORS, JSON parser, /health, routes
|
||||
│ │ ├── config.ts # Env config: raspi, PIPER_URL, LLAMA_CPP_URL, PORT
|
||||
│ │ ├── routes/router.ts # Mounts all controllers
|
||||
│ │ ├── services/raspi.service.ts # Axios proxy layer to Pi endpoints
|
||||
│ │ └── 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
|
||||
│ │ └── tts.controller.ts # TTS synthesis via Piper
|
||||
│ └── 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 backend Express
|
||||
│ │ ├── 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 (remote, port 8000)
|
||||
|
||||
The Raspberry Pi runs a lightweight HTTP server exposing hardware control endpoints. The `raspi/` source directory is no longer part of this repository — it lives on the Pi itself.
|
||||
|
||||
**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)
|
||||
|
||||
### Hardware source files (on Pi)
|
||||
|
||||
- **`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 server providing frontend/mobile API, proxying hardware commands to the Raspberry Pi, and managing TTS/LLM integration.
|
||||
|
||||
### 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` | `5000` | Backend listen port |
|
||||
| `PIPER_URL` | `''` | Piper TTS service URL |
|
||||
| `LLAMA_CPP_URL` | `''` | LLM inference service URL |
|
||||
| `LLAMA_API_KEY` | `''` | LLM API key |
|
||||
| `LLAMA_PREAMBLE` | `''` | Path or content for LLM preamble |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
index.ts → Express app, CORS, JSON parser, /health endpoint
|
||||
routes/router.ts → Mounts all controllers under /motor, /audio, /commands, /settings, /tts
|
||||
config.ts → Mutable getter/setter env vars (runtime update via PUT /settings)
|
||||
raspi.service.ts → Axios proxy methods for Pi endpoints + 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.
|
||||
- **`tts.controller.ts`** — `POST /tts { text, lang }` → Synthesizes audio via Piper TTS service. Saves WAV files to `/tmp/quibot-audio/tts/`.
|
||||
|
||||
### 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 backend Express (or Pi) |
|
||||
| `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 backend Express `/motor/stop` |
|
||||
| POST | `/api/motor/step/:direction` | Proxies to backend Express `/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
|
||||
|
||||
### Backend Express (`backend/`) — port 5000
|
||||
|
||||
| Method | Path | Body | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | — | Returns settings object |
|
||||
| POST | `/commands` | `{ task }` | Proxy to raspi `/run` |
|
||||
| POST | `/motor/step/forward` | — | Motor forward proxy to Pi |
|
||||
| POST | `/motor/step/backward` | — | Motor backward proxy to Pi (maps to `/backwards`) |
|
||||
| POST | `/motor/stop` | — | Motor stop proxy to Pi |
|
||||
| POST | `/motor/upload` | multipart file | Audio upload via multer → proxied to Pi |
|
||||
| GET | `/audio/incoming` | — | List incoming audio files from Pi |
|
||||
| POST | `/audio/lock/:filename` | — | Lock audio file on Pi |
|
||||
| POST | `/audio/unlock/:filename` | — | Unlock audio file on Pi |
|
||||
| POST | `/audio/cancel/:filename` | — | Cancel locked audio on Pi |
|
||||
| POST | `/audio/process/:filename` | — | Mark processed on Pi |
|
||||
| GET | `/settings` | — | Returns config |
|
||||
| PUT | `/settings` | `{ raspberryPi: { host, port }, token }` | Update runtime config |
|
||||
| POST | `/tts` | query: `text`, `lang` | Synthesize speech via Piper TTS |
|
||||
|
||||
### Raspberry Pi HTTP Server (remote, 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`).
|
||||
|
||||
---
|
||||
|
||||
## 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 } })
|
||||
→ Backend Express /motor/step/forward
|
||||
→ raspi.service.motorStepForward()
|
||||
→ Pi FastAPI /motor/step/forward?token=...
|
||||
→ motor_step("forward") in daemon thread on Pi
|
||||
→ 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)
|
||||
```
|
||||
|
||||
### TTS Synthesis
|
||||
```
|
||||
User triggers speech in web UI
|
||||
→ POST /tts?text=hello&lang=ca&token=...
|
||||
→ Backend Express tts.controller.ts
|
||||
→ piperService.synthesize() → Piper TTS service
|
||||
→ WAV file saved to /tmp/quibot-audio/tts/{uuid}.wav
|
||||
→ Returns audioUrl + filename
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Tests reside on the Pi alongside the hardware source code, not in this repository.
|
||||
|
||||
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 proxies Pi endpoints** — motor/audio commands forwarded via raspi.service.ts
|
||||
4. **Motor commands are fire-and-forget** — motor runs in daemon thread on Pi 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)
|
||||
- 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 |
@@ -49,6 +49,14 @@
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { loadRecorderSettings } from "@/lib/recorder-settings";
|
||||
import { getStrings, type Locale, t } from "@/lib/translations";
|
||||
|
||||
function formatDuration(durationMs: number) {
|
||||
const totalSeconds = Math.floor(durationMs / 1000);
|
||||
@@ -50,14 +51,20 @@ export default function RecorderScreen() {
|
||||
const [backendUrl, setBackendUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
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 [recordingUri, setRecordingUri] = useState<string | null>(null);
|
||||
const [recordingMs, setRecordingMs] = useState(0);
|
||||
const [statusMessage, setStatusMessage] = useState(
|
||||
"Ready to record and send audio.",
|
||||
);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [responsePreview, setResponsePreview] = useState("");
|
||||
const [llmResponseText, setLlmResponseText] = useState("");
|
||||
const [transcriptionText, setTranscriptionText] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isHolding, setIsHolding] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const recordingRef = useRef<Audio.Recording | null>(null);
|
||||
const soundRef = useRef<Audio.Sound | null>(null);
|
||||
|
||||
const refreshSettings = useCallback(() => {
|
||||
let isMounted = true;
|
||||
@@ -73,9 +80,11 @@ export default function RecorderScreen() {
|
||||
setBackendUrl(settings.backendUrl);
|
||||
setAuthToken(settings.authToken);
|
||||
setFieldName(settings.fieldName);
|
||||
setLocale(settings.language);
|
||||
setStrings(getStrings(settings.language));
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setStatusMessage("Could not load saved backend settings.");
|
||||
setStatusMessage(strings.loadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,18 +124,148 @@ export default function RecorderScreen() {
|
||||
};
|
||||
}, [recording]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void unloadSound();
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function unloadSound() {
|
||||
if (soundRef.current) {
|
||||
try {
|
||||
await soundRef.current.stopAsync();
|
||||
await soundRef.current.unloadAsync();
|
||||
} catch (err) {
|
||||
console.log("[TTS] Error unloading sound:", err);
|
||||
}
|
||||
soundRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
async function speakWithAudio(audioUrl: string, backendBase: string) {
|
||||
if (!audioUrl) return false;
|
||||
|
||||
await unloadSound();
|
||||
|
||||
try {
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: true,
|
||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
shouldDuckAndroid: true,
|
||||
staysActiveInBackground: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("[TTS] Audio mode error:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
const fullUrl = audioUrl.startsWith("http")
|
||||
? audioUrl
|
||||
: `${backendBase.replace(/\/+$/, "")}/${audioUrl.replace(/^\/+/, "")}`;
|
||||
|
||||
console.log("[TTS] Loading audio from:", fullUrl);
|
||||
setIsPlaying(true);
|
||||
setStatusMessage(strings.playing);
|
||||
|
||||
const { sound } = await Audio.Sound.createAsync(
|
||||
{ uri: fullUrl },
|
||||
{ shouldPlay: true, volume: 1.0 },
|
||||
(status) => {
|
||||
if (status.isLoaded && status.didJustFinish) {
|
||||
console.log("[TTS] Audio playback finished");
|
||||
void unloadSound();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
soundRef.current = sound;
|
||||
const status = await sound.getStatusAsync();
|
||||
const durationMs = status.isLoaded ? (status.durationMillis ?? 0) : 0;
|
||||
console.log("[TTS] Playing audio, duration:", durationMs, "ms");
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log("[TTS] Audio playback error:", err);
|
||||
setIsPlaying(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function speakSequentially(texts: string[]) {
|
||||
if (texts.length === 0) return;
|
||||
|
||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const text = texts[i];
|
||||
if (!text || !text.trim()) continue;
|
||||
|
||||
try {
|
||||
setStatusMessage(strings.playing);
|
||||
console.log("[TTS] Generating TTS audio for text:", text.substring(0, 50));
|
||||
|
||||
const localeLang = locale === "ca" ? "ca" : "en";
|
||||
const ttsParams = new URLSearchParams({
|
||||
text: text.trim(),
|
||||
language: localeLang,
|
||||
});
|
||||
|
||||
if (authToken.trim()) {
|
||||
ttsParams.append("token", authToken.trim());
|
||||
}
|
||||
|
||||
const ttsUrl = `${trimmedUrl}/tts?${ttsParams.toString()}`;
|
||||
const ttsResponse = await fetch(ttsUrl, { method: "POST" });
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
const errText = await ttsResponse.text();
|
||||
console.log("[TTS] TTS endpoint error:", ttsResponse.status, errText);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ttsData = await ttsResponse.json();
|
||||
|
||||
if (!ttsData.audioUrl) {
|
||||
console.log("[TTS] No audioUrl in response:", ttsData);
|
||||
continue;
|
||||
}
|
||||
|
||||
const played = await speakWithAudio(ttsData.audioUrl, trimmedUrl);
|
||||
if (!played) {
|
||||
setStatusMessage(strings.uploadFailed);
|
||||
}
|
||||
|
||||
if (i < texts.length - 1) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[TTS] speakSequentially error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function speak(text: string) {
|
||||
const texts = [text].filter(Boolean);
|
||||
await speakSequentially(texts);
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
await unloadSound();
|
||||
setTranscriptionText("");
|
||||
setResponsePreview("");
|
||||
setLlmResponseText("");
|
||||
setRecordingUri(null);
|
||||
|
||||
const permission = await Audio.requestPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
setStatusMessage("Microphone permission was denied.");
|
||||
setStatusMessage(strings.micPermissionDenied);
|
||||
Alert.alert(
|
||||
"Microphone access required",
|
||||
"Enable microphone access to record audio.",
|
||||
strings.micAccessRequiredTitle,
|
||||
strings.micAccessRequiredMsg,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -144,26 +283,30 @@ export default function RecorderScreen() {
|
||||
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
||||
);
|
||||
|
||||
recordingRef.current = result.recording;
|
||||
setRecording(result.recording);
|
||||
setRecordingMs(0);
|
||||
setStatusMessage("Recording in progress.");
|
||||
setStatusMessage(strings.recording);
|
||||
} catch (error) {
|
||||
setStatusMessage("Recording could not be started.");
|
||||
setStatusMessage(strings.couldNotStartRecording);
|
||||
Alert.alert(
|
||||
"Recording failed",
|
||||
error instanceof Error ? error.message : "Unknown recording error.",
|
||||
strings.recordingFailedTitle,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
if (!recording) {
|
||||
async function stopRecordingAndUpload() {
|
||||
if (!recordingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[APP] stopRecordingAndUpload called");
|
||||
|
||||
try {
|
||||
const activeRecording = recording;
|
||||
const activeRecording = recordingRef.current;
|
||||
const currentStatus = await activeRecording.getStatusAsync();
|
||||
const durationMillis = currentStatus.durationMillis ?? 0;
|
||||
|
||||
await activeRecording.stopAndUnloadAsync();
|
||||
await Audio.setAudioModeAsync({
|
||||
@@ -172,25 +315,117 @@ export default function RecorderScreen() {
|
||||
});
|
||||
|
||||
const uri = activeRecording.getURI();
|
||||
recordingRef.current = null;
|
||||
setRecording(null);
|
||||
setRecordingMs(currentStatus.durationMillis ?? recordingMs);
|
||||
setRecordingUri(uri);
|
||||
setStatusMessage(
|
||||
uri ? "Recording finished. Preparing to send voice message." : "Recording finished.",
|
||||
);
|
||||
setRecordingMs(durationMillis);
|
||||
|
||||
if (uri && backendUrl.trim()) {
|
||||
await uploadRecording(uri);
|
||||
if (!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) {
|
||||
setIsUploading(true);
|
||||
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();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}. ${responseText}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(responseText);
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
|
||||
const textsToSpeak: string[] = [];
|
||||
|
||||
if (data.transcription) {
|
||||
setTranscriptionText(data.transcription);
|
||||
}
|
||||
|
||||
if (data.llmResponse) {
|
||||
setLlmResponseText(data.llmResponse);
|
||||
textsToSpeak.push(data.llmResponse);
|
||||
}
|
||||
|
||||
if (textsToSpeak.length > 0) {
|
||||
setStatusMessage(strings.voiceMessageSent + ". " + strings.playing);
|
||||
void speakSequentially(textsToSpeak);
|
||||
} else {
|
||||
setLlmResponseText("");
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log("[APP] JSON parse failed:", parseError, "Response was:", responseText.substring(0, 200));
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
setTranscriptionText("");
|
||||
setLlmResponseText("");
|
||||
}
|
||||
|
||||
setStatusMessage(strings.voiceMessageSent);
|
||||
} catch (error) {
|
||||
setStatusMessage(strings.uploadFailed);
|
||||
Alert.alert(
|
||||
strings.uploadFailed,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
} else {
|
||||
setStatusMessage(strings.noBackendUrl);
|
||||
setIsUploading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage("Recording could not be stopped cleanly.");
|
||||
recordingRef.current = null;
|
||||
setRecording(null);
|
||||
setStatusMessage(strings.stopFailedTitle);
|
||||
Alert.alert(
|
||||
"Stop failed",
|
||||
error instanceof Error ? error.message : "Unknown stop error.",
|
||||
strings.stopFailedTitle,
|
||||
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) {
|
||||
const targetUri = uriOverride ?? recordingUri;
|
||||
|
||||
@@ -198,17 +433,23 @@ export default function RecorderScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedUrl = backendUrl.trim();
|
||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
||||
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
||||
? trimmedUrl
|
||||
: `${trimmedUrl}/audio/upload`;
|
||||
|
||||
if (!trimmedUrl) {
|
||||
Alert.alert("Missing backend URL", "Enter the backend endpoint first.");
|
||||
if (!uploadUrl) {
|
||||
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setStatusMessage("Uploading recording.");
|
||||
setStatusMessage(strings.uploadingRecording);
|
||||
await unloadSound();
|
||||
setTranscriptionText("");
|
||||
setResponsePreview("");
|
||||
setLlmResponseText("");
|
||||
|
||||
const mimeType = buildMimeType(targetUri);
|
||||
const extension = buildFileExtension(targetUri);
|
||||
@@ -226,33 +467,72 @@ export default function RecorderScreen() {
|
||||
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(trimmedUrl, {
|
||||
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(`Upload failed with ${response.status}. ${responseText}`);
|
||||
throw new Error(`${response.status}. ${responseText}`);
|
||||
}
|
||||
|
||||
setStatusMessage("Upload complete.");
|
||||
try {
|
||||
const data = JSON.parse(responseText);
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
|
||||
const textsToSpeak: string[] = [];
|
||||
|
||||
if (data.transcription) {
|
||||
setTranscriptionText(data.transcription);
|
||||
textsToSpeak.push(data.transcription);
|
||||
}
|
||||
|
||||
if (data.llmResponse) {
|
||||
setLlmResponseText(data.llmResponse);
|
||||
textsToSpeak.push(data.llmResponse);
|
||||
}
|
||||
|
||||
if (textsToSpeak.length > 0) {
|
||||
setStatusMessage(strings.voiceMessageSent + ". " + strings.playing);
|
||||
void speakSequentially(textsToSpeak);
|
||||
} else {
|
||||
setLlmResponseText("");
|
||||
}
|
||||
} catch {
|
||||
setResponsePreview(responseText.slice(0, 400));
|
||||
setTranscriptionText("");
|
||||
setLlmResponseText("");
|
||||
}
|
||||
|
||||
setStatusMessage(strings.uploadComplete);
|
||||
} catch (error) {
|
||||
setStatusMessage("Upload failed.");
|
||||
setStatusMessage(strings.uploadFailed);
|
||||
Alert.alert(
|
||||
"Upload failed",
|
||||
error instanceof Error ? error.message : "Unknown upload error.",
|
||||
strings.uploadFailed,
|
||||
error instanceof Error ? error.message : "",
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpeak() {
|
||||
const texts = [transcriptionText, llmResponseText].filter(Boolean);
|
||||
void speakSequentially(texts);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
@@ -265,59 +545,111 @@ export default function RecorderScreen() {
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.heroTopRow}>
|
||||
<View style={styles.heroBadge}>
|
||||
<Text style={styles.heroBadgeText}>Assistant Voice</Text>
|
||||
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => router.push("/settings")} style={styles.settingsLink}>
|
||||
<Text style={styles.settingsLinkText}>Settings</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push("/settings")}
|
||||
hitSlop={10}
|
||||
style={styles.settingsCog}
|
||||
>
|
||||
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
|
||||
fill="#d3deea"
|
||||
/>
|
||||
<Path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08Z"
|
||||
stroke="#d3deea"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>
|
||||
Record a voice message and send it to your backend.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.meterValueCentered}>
|
||||
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
||||
{formatDuration(recordingMs)}
|
||||
</Text>
|
||||
|
||||
<Pressable
|
||||
disabled={isUploading}
|
||||
onPress={recording ? stopRecording : startRecording}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={[
|
||||
styles.micButton,
|
||||
recording ? styles.stopButton : styles.recordButton,
|
||||
isHolding ? styles.holdingButton : styles.idleButton,
|
||||
isUploading && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
{isUploading ? (
|
||||
<ActivityIndicator color="#fff6f3" size="large" />
|
||||
) : (
|
||||
<Text style={styles.micButtonText}>
|
||||
{recording ? "Stop" : "Record"}
|
||||
</Text>
|
||||
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"
|
||||
fill="#fff6f3"
|
||||
stroke="#fff6f3"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<Path
|
||||
d="M19 10v1a7 7 0 0 1-14 0v-1"
|
||||
stroke="#fff6f3"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M12 18v3"
|
||||
stroke="#fff6f3"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.statusText}>{statusMessage}</Text>
|
||||
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
|
||||
<Text style={styles.helperText}>
|
||||
{backendUrl.trim()
|
||||
? recording
|
||||
? "Tap again when you finish speaking."
|
||||
: "Tap the button to start a new voice message."
|
||||
: "Open settings to add your backend URL before sending voice messages."}
|
||||
{isHolding
|
||||
? releaseLabel
|
||||
: backendUrl.trim()
|
||||
? holdLabel
|
||||
: openSettingsLabel}
|
||||
</Text>
|
||||
|
||||
{responsePreview ? (
|
||||
{transcriptionText ? (
|
||||
<View style={styles.transcriptionBox}>
|
||||
<Text style={styles.transcriptionLabel}>{strings.yourMessage}</Text>
|
||||
<Text style={styles.transcriptionText}>{transcriptionText}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{llmResponseText ? (
|
||||
<View style={styles.llmResponseBox}>
|
||||
<View style={styles.llmResponseHeader}>
|
||||
<Text style={styles.llmResponseLabel}>{strings.aiReply}</Text>
|
||||
<Pressable onPress={handleSpeak} style={styles.speakButton}>
|
||||
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M11 5L6 9H2v6h4l5 4V5z" fill="#13304a" />
|
||||
<Path d="M15.5 8.5a5.5 5.5 0 0 1 0 7" stroke="#13304a" strokeWidth="2" strokeLinecap="round" />
|
||||
<Path d="M18.5 5.5a9 9 0 0 1 0 13" stroke="#13304a" strokeWidth="2" strokeLinecap="round" />
|
||||
</Svg>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.llmResponseText}>{llmResponseText}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{responsePreview && !transcriptionText && !llmResponseText ? (
|
||||
<View style={styles.responseBox}>
|
||||
<Text style={styles.responseLabel}>Server response</Text>
|
||||
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
|
||||
<Text style={styles.responseText}>{responsePreview}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -333,17 +665,17 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 32,
|
||||
paddingTop: 8,
|
||||
gap: 18,
|
||||
},
|
||||
hero: {
|
||||
backgroundColor: "#13304a",
|
||||
borderRadius: 28,
|
||||
backgroundColor: "transparent",
|
||||
paddingHorizontal: 22,
|
||||
paddingVertical: 24,
|
||||
gap: 12,
|
||||
paddingTop: 40,
|
||||
},
|
||||
heroTopRow: {
|
||||
alignItems: "center",
|
||||
@@ -351,7 +683,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
heroBadge: {
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#f2b15d",
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
@@ -364,22 +695,14 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
settingsLink: {
|
||||
borderColor: "#58718d",
|
||||
settingsCog: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
},
|
||||
settingsLinkText: {
|
||||
color: "#d3deea",
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
},
|
||||
subtitle: {
|
||||
color: "#d3deea",
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
backgroundColor: "#13304a",
|
||||
marginLeft: 12,
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: "#fffaf1",
|
||||
@@ -388,6 +711,8 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 18,
|
||||
alignSelf: "center",
|
||||
maxWidth: 340,
|
||||
},
|
||||
meterValueCentered: {
|
||||
color: "#d04f2d",
|
||||
@@ -404,17 +729,21 @@ const styles = StyleSheet.create({
|
||||
width: 164,
|
||||
alignSelf: "center",
|
||||
},
|
||||
recordButton: {
|
||||
backgroundColor: "#d04f2d",
|
||||
idleButton: {
|
||||
backgroundColor: "#13304a",
|
||||
},
|
||||
stopButton: {
|
||||
backgroundColor: "#8c1c13",
|
||||
holdingButton: {
|
||||
backgroundColor: "#d04f2d",
|
||||
transform: [{ scale: 1.08 }],
|
||||
},
|
||||
micButtonText: {
|
||||
color: "#fff6f3",
|
||||
fontSize: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: "800",
|
||||
},
|
||||
recordingLabel: {
|
||||
fontSize: 18,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.45,
|
||||
},
|
||||
@@ -422,11 +751,13 @@ const styles = StyleSheet.create({
|
||||
color: "#1f2d3d",
|
||||
fontSize: 15,
|
||||
lineHeight: 21,
|
||||
textAlign: "center",
|
||||
},
|
||||
helperText: {
|
||||
color: "#665f54",
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
responseBox: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
@@ -440,10 +771,65 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
},
|
||||
responseText: {
|
||||
color: "#36475a",
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
llmResponseBox: {
|
||||
backgroundColor: "#e8f4e8",
|
||||
borderRadius: 16,
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: "#b8d9b8",
|
||||
},
|
||||
llmResponseHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
llmResponseLabel: {
|
||||
color: "#2a6a2a",
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
},
|
||||
llmResponseText: {
|
||||
color: "#2d4a2d",
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
speakButton: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
borderRadius: 20,
|
||||
padding: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#dccfb9",
|
||||
},
|
||||
transcriptionBox: {
|
||||
backgroundColor: "#e8ecf4",
|
||||
borderRadius: 16,
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: "#b8c9d9",
|
||||
},
|
||||
transcriptionLabel: {
|
||||
color: "#1a4a6a",
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
textTransform: "uppercase",
|
||||
textAlign: "center",
|
||||
},
|
||||
transcriptionText: {
|
||||
color: "#1f3a52",
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Picker } from "@react-native-picker/picker";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -16,11 +17,20 @@ import {
|
||||
loadRecorderSettings,
|
||||
saveRecorderSettings,
|
||||
} from "@/lib/recorder-settings";
|
||||
import { AVAILABLE_LOCALES, t, type Locale, getStrings } from "@/lib/translations";
|
||||
|
||||
function localeLabel(locale: Locale) {
|
||||
if (locale === "ca") return "Catal\u00e0";
|
||||
if (locale === "en") return "English";
|
||||
return locale;
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [backendUrl, setBackendUrl] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
const [fieldName, setFieldName] = useState("file");
|
||||
const [language, setLanguage] = useState<Locale>("ca");
|
||||
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -36,9 +46,11 @@ export default function SettingsScreen() {
|
||||
setBackendUrl(settings.backendUrl);
|
||||
setAuthToken(settings.authToken);
|
||||
setFieldName(settings.fieldName);
|
||||
setLanguage(settings.language);
|
||||
setStrings(getStrings(settings.language));
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
Alert.alert("Settings error", "Could not load backend settings.");
|
||||
Alert.alert(strings.loadError, strings.loadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,19 +62,28 @@ export default function SettingsScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const langStrings = getStrings(language);
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await saveRecorderSettings({
|
||||
authToken,
|
||||
backendUrl,
|
||||
fieldName,
|
||||
language,
|
||||
});
|
||||
setStrings(getStrings(language));
|
||||
router.back();
|
||||
} catch {
|
||||
Alert.alert("Save failed", "Could not save backend settings.");
|
||||
Alert.alert(langStrings.saveError, langStrings.saveError);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLanguageChange(val: Locale) {
|
||||
setLanguage(val);
|
||||
setStrings(getStrings(val));
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
@@ -76,54 +97,73 @@ export default function SettingsScreen() {
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable onPress={() => router.back()} style={styles.navButton}>
|
||||
<Text style={styles.navButtonText}>Back</Text>
|
||||
<Text style={styles.navButtonText}>{langStrings.back}</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Settings</Text>
|
||||
<Text style={styles.title}>{langStrings.settingsTitle}</Text>
|
||||
<Pressable onPress={handleSave} style={styles.navButton}>
|
||||
<Text style={styles.navButtonText}>Save</Text>
|
||||
<Text style={styles.navButtonText}>{langStrings.save}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.label}>Backend URL</Text>
|
||||
<Text style={styles.label}>{langStrings.backendUrl}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
onChangeText={setBackendUrl}
|
||||
placeholder="https://api.example.com/upload"
|
||||
placeholder={langStrings.urlPlaceholder}
|
||||
placeholderTextColor="#8f8a7c"
|
||||
style={styles.input}
|
||||
value={backendUrl}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Bearer token</Text>
|
||||
<Text style={styles.label}>{langStrings.bearerToken}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setAuthToken}
|
||||
placeholder="Optional"
|
||||
placeholder={langStrings.tokenOptional}
|
||||
placeholderTextColor="#8f8a7c"
|
||||
secureTextEntry
|
||||
style={styles.input}
|
||||
value={authToken}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Form field name</Text>
|
||||
<Text style={styles.label}>{langStrings.formFieldName}</Text>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setFieldName}
|
||||
placeholder="file"
|
||||
placeholder={langStrings.fieldNamePlaceholder}
|
||||
placeholderTextColor="#8f8a7c"
|
||||
style={styles.input}
|
||||
value={fieldName}
|
||||
/>
|
||||
|
||||
<Text style={styles.helperText}>
|
||||
The recording is uploaded as multipart field `{fieldName.trim() || "file"}`.
|
||||
{t("helperText", language, fieldName.trim() || "file")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.label}>{langStrings.languageTitle}</Text>
|
||||
<View style={styles.pickerWrapper}>
|
||||
<Picker
|
||||
selectedValue={language}
|
||||
onValueChange={handleLanguageChange}
|
||||
style={styles.picker}
|
||||
>
|
||||
{AVAILABLE_LOCALES.map((loc) => (
|
||||
<Picker.Item
|
||||
key={loc}
|
||||
label={localeLabel(loc)}
|
||||
value={loc}
|
||||
/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
@@ -199,4 +239,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
pickerWrapper: {
|
||||
backgroundColor: "#f7f0e0",
|
||||
borderColor: "#d9ccb5",
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
overflow: "hidden",
|
||||
},
|
||||
picker: {
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
import type { Locale } from "./translations";
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
authToken: "recorder.authToken",
|
||||
backendUrl: "recorder.backendUrl",
|
||||
fieldName: "recorder.fieldName",
|
||||
language: "recorder.language",
|
||||
};
|
||||
|
||||
export type RecorderSettings = {
|
||||
authToken: string;
|
||||
backendUrl: string;
|
||||
fieldName: string;
|
||||
language: Locale;
|
||||
};
|
||||
|
||||
export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||
@@ -17,6 +21,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||
STORAGE_KEYS.backendUrl,
|
||||
STORAGE_KEYS.authToken,
|
||||
STORAGE_KEYS.fieldName,
|
||||
STORAGE_KEYS.language,
|
||||
]);
|
||||
|
||||
const values = Object.fromEntries(entries);
|
||||
@@ -25,6 +30,7 @@ export async function loadRecorderSettings(): Promise<RecorderSettings> {
|
||||
authToken: values[STORAGE_KEYS.authToken] ?? "",
|
||||
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
|
||||
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
|
||||
language: (values[STORAGE_KEYS.language] as Locale) ?? "ca",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,5 +39,6 @@ export async function saveRecorderSettings(settings: RecorderSettings) {
|
||||
[STORAGE_KEYS.backendUrl, settings.backendUrl],
|
||||
[STORAGE_KEYS.authToken, settings.authToken],
|
||||
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
|
||||
[STORAGE_KEYS.language, settings.language],
|
||||
]);
|
||||
}
|
||||
|
||||
106
apk/lib/translations/index.ts
Normal file
106
apk/lib/translations/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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.",
|
||||
aiReply: "Quibot reply",
|
||||
yourMessage: "Your message",
|
||||
playing: "Playing audio...",
|
||||
};
|
||||
}
|
||||
|
||||
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.",
|
||||
aiReply: "Resposta del Quibot",
|
||||
yourMessage: "El teu missatge",
|
||||
playing: "Reproduint àudio...",
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
3109
apk/package-lock.json
generated
3109
apk/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "~54.0.33",
|
||||
"@react-native-picker/picker": "2.11.1",
|
||||
"expo": "~54.0.35",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
|
||||
23
backend/.env.example
Normal file
23
backend/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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=5000
|
||||
|
||||
# Piper TTS config (optional — local model)
|
||||
PIPER_MODELS_DIR=./piper
|
||||
PIPER_MODEL=./src/ca_ES-upc_ona-medium.onnx
|
||||
|
||||
# Remote Piper TTS service (alternative to local model)
|
||||
PIPER_URL=
|
||||
|
||||
LLAMA_CPP_URL=https://ollama.epsem.aranroig.com/v1/chat/completitions
|
||||
LLAMA_PREAMBLE=./prompts/preamble.md
|
||||
LLAMA_API_KEY=your_api_key
|
||||
|
||||
# MCP server (Python FastMCP) — SSH-tunelled from remote machine
|
||||
MCP_URL=http://localhost:5001
|
||||
8
backend/.gitignore
vendored
8
backend/.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
__pycache__/
|
||||
venv/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
quibot-audio-*.txt
|
||||
**/quibot-audio-*.txt
|
||||
|
||||
210
backend/main.py
210
backend/main.py
@@ -1,210 +0,0 @@
|
||||
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel
|
||||
import RPi.GPIO as GPIO
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
INCOMING_DIR = Path("/tmp/quibot-audio/incoming")
|
||||
LOCKED_DIR = Path("/tmp/quibot-audio/locked")
|
||||
PROCESSED_DIR = Path("/tmp/quibot-audio/processed")
|
||||
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LOCKED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -------------------------
|
||||
# GPIO SETUP
|
||||
# -------------------------
|
||||
STEP = 23
|
||||
DIR = 24
|
||||
EN = 25
|
||||
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setup(STEP, GPIO.OUT)
|
||||
GPIO.setup(DIR, GPIO.OUT)
|
||||
GPIO.setup(EN, GPIO.OUT)
|
||||
|
||||
GPIO.output(EN, GPIO.LOW)
|
||||
|
||||
|
||||
motor_thread = None
|
||||
|
||||
def step_motor(steps, direction, delay=0.001):
|
||||
GPIO.output(DIR, direction)
|
||||
|
||||
for _ in range(steps):
|
||||
GPIO.output(STEP, GPIO.HIGH)
|
||||
time.sleep(delay)
|
||||
GPIO.output(STEP, GPIO.LOW)
|
||||
time.sleep(delay)
|
||||
|
||||
def motor_step(dir):
|
||||
dir_pin = GPIO.HIGH if dir == "forward" else GPIO.LOW
|
||||
time.sleep(0.02) # small delay before starting
|
||||
print("Motor running...")
|
||||
step_motor(200, dir_pin, 0.001)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# SAFE COMMAND WHITELIST
|
||||
# -------------------------
|
||||
COMMANDS = {
|
||||
"restart_nginx": ["sudo", "systemctl", "restart", "nginx"],
|
||||
"uptime": ["uptime"],
|
||||
"update": ["sudo", "apt", "update"]
|
||||
}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# API ENDPOINTS
|
||||
# -------------------------
|
||||
|
||||
@app.post("/run")
|
||||
def run_task(task: str, token: str):
|
||||
if token != "MY_SECRET_TOKEN":
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
if task not in COMMANDS:
|
||||
raise HTTPException(status_code=400, detail="Invalid task")
|
||||
|
||||
try:
|
||||
result = subprocess.check_output(COMMANDS[task], text=True)
|
||||
return {"output": result}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {"error": e.output}
|
||||
|
||||
|
||||
@app.post("/motor/step/forward")
|
||||
def start_motor(token: str):
|
||||
global motor_thread
|
||||
|
||||
if token != "MY_SECRET_TOKEN":
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
|
||||
motor_thread = threading.Thread(target=motor_step, args=("forward",), daemon=True)
|
||||
motor_thread.start()
|
||||
|
||||
return {"status": "motor started"}
|
||||
|
||||
@app.post("/motor/step/backwards")
|
||||
def start_motor(token: str):
|
||||
global motor_thread
|
||||
|
||||
if token != "MY_SECRET_TOKEN":
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
|
||||
motor_thread = threading.Thread(target=motor_step, args=("backwards",), daemon=True)
|
||||
motor_thread.start()
|
||||
|
||||
return {"status": "motor started"}
|
||||
|
||||
@app.post("/motor/stop")
|
||||
def stop_motor(token: str):
|
||||
if token != "MY_SECRET_TOKEN":
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
GPIO.output(EN, GPIO.HIGH) # disable driver
|
||||
|
||||
return {"status": "motor stopped"}
|
||||
|
||||
|
||||
@app.post("/audio/upload")
|
||||
async def upload_audio(file: UploadFile = File(...), format: str = "wav"):
|
||||
raw_content = await file.read()
|
||||
|
||||
checksum = hashlib.sha256(raw_content).hexdigest()[:16]
|
||||
filename = f"{checksum[:10]}-{uuid.uuid4().hex[:8]}.wav"
|
||||
|
||||
filepath = INCOMING_DIR / filename
|
||||
filepath.write_bytes(raw_content)
|
||||
|
||||
return {"status": "received", "filename": str(filepath), "lock_url": f"/audio/lock/{filepath.name}"}
|
||||
|
||||
|
||||
@app.get("/audio/incoming")
|
||||
def list_incoming():
|
||||
files = []
|
||||
for f in sorted(INCOMING_DIR.iterdir()):
|
||||
meta = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size_bytes": meta.st_size,
|
||||
"modified_iso": time.ctime(meta.st_mtime),
|
||||
})
|
||||
return {"count": len(files), "files": files}
|
||||
|
||||
|
||||
@app.post("/audio/lock/{filename}")
|
||||
def lock_audio(filename: str):
|
||||
src = INCOMING_DIR / filename
|
||||
dst = LOCKED_DIR / filename
|
||||
|
||||
if not src.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File {filename} not found")
|
||||
|
||||
if dst.exists():
|
||||
return {"status": "already_locked", "filename": filename}
|
||||
|
||||
os.rename(str(src), str(dst))
|
||||
return {"status": "locked", "filename": filename}
|
||||
|
||||
|
||||
@app.post("/audio/unlock/{filename}")
|
||||
def unlock_audio(filename: str):
|
||||
src = LOCKED_DIR / filename
|
||||
dst = INCOMING_DIR / filename
|
||||
|
||||
if not src.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File {filename} not found")
|
||||
|
||||
os.rename(str(src), str(dst))
|
||||
return {"status": "unlocked", "filename": filename}
|
||||
|
||||
|
||||
@app.post("/audio/cancel/{filename}")
|
||||
def cancel_audio(filename: str):
|
||||
src = LOCKED_DIR / filename
|
||||
dst = INCOMING_DIR / filename
|
||||
|
||||
if not src.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File {filename} not found")
|
||||
|
||||
os.rename(str(src), str(dst))
|
||||
return {"status": "cancelled", "filename": filename}
|
||||
|
||||
|
||||
@app.post("/audio/process/{filename}")
|
||||
def process_audio(filename: str):
|
||||
locked = LOCKED_DIR / filename
|
||||
processed = PROCESSED_DIR / filename
|
||||
|
||||
if not locked.exists():
|
||||
raise HTTPException(status_code=404, detail=f"File {filename} not found")
|
||||
|
||||
os.rename(str(locked), str(processed))
|
||||
return {"status": "processed", "filename": filename}
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown():
|
||||
global motor_running
|
||||
motor_running = False
|
||||
GPIO.output(EN, GPIO.HIGH)
|
||||
GPIO.cleanup()
|
||||
2606
backend/package-lock.json
generated
Normal file
2606
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
backend/package.json
Normal file
29
backend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
BIN
backend/piper/ca_ES-upc_ona-medium.onnx
Normal file
BIN
backend/piper/ca_ES-upc_ona-medium.onnx
Normal file
Binary file not shown.
493
backend/piper/ca_ES-upc_ona-medium.onnx.json
Normal file
493
backend/piper/ca_ES-upc_ona-medium.onnx.json
Normal file
@@ -0,0 +1,493 @@
|
||||
{
|
||||
"audio": {
|
||||
"sample_rate": 22050,
|
||||
"quality": "medium"
|
||||
},
|
||||
"espeak": {
|
||||
"voice": "ca"
|
||||
},
|
||||
"inference": {
|
||||
"noise_scale": 0.667,
|
||||
"length_scale": 1,
|
||||
"noise_w": 0.8
|
||||
},
|
||||
"phoneme_type": "espeak",
|
||||
"phoneme_map": {},
|
||||
"phoneme_id_map": {
|
||||
"_": [
|
||||
0
|
||||
],
|
||||
"^": [
|
||||
1
|
||||
],
|
||||
"$": [
|
||||
2
|
||||
],
|
||||
" ": [
|
||||
3
|
||||
],
|
||||
"!": [
|
||||
4
|
||||
],
|
||||
"'": [
|
||||
5
|
||||
],
|
||||
"(": [
|
||||
6
|
||||
],
|
||||
")": [
|
||||
7
|
||||
],
|
||||
",": [
|
||||
8
|
||||
],
|
||||
"-": [
|
||||
9
|
||||
],
|
||||
".": [
|
||||
10
|
||||
],
|
||||
":": [
|
||||
11
|
||||
],
|
||||
";": [
|
||||
12
|
||||
],
|
||||
"?": [
|
||||
13
|
||||
],
|
||||
"a": [
|
||||
14
|
||||
],
|
||||
"b": [
|
||||
15
|
||||
],
|
||||
"c": [
|
||||
16
|
||||
],
|
||||
"d": [
|
||||
17
|
||||
],
|
||||
"e": [
|
||||
18
|
||||
],
|
||||
"f": [
|
||||
19
|
||||
],
|
||||
"h": [
|
||||
20
|
||||
],
|
||||
"i": [
|
||||
21
|
||||
],
|
||||
"j": [
|
||||
22
|
||||
],
|
||||
"k": [
|
||||
23
|
||||
],
|
||||
"l": [
|
||||
24
|
||||
],
|
||||
"m": [
|
||||
25
|
||||
],
|
||||
"n": [
|
||||
26
|
||||
],
|
||||
"o": [
|
||||
27
|
||||
],
|
||||
"p": [
|
||||
28
|
||||
],
|
||||
"q": [
|
||||
29
|
||||
],
|
||||
"r": [
|
||||
30
|
||||
],
|
||||
"s": [
|
||||
31
|
||||
],
|
||||
"t": [
|
||||
32
|
||||
],
|
||||
"u": [
|
||||
33
|
||||
],
|
||||
"v": [
|
||||
34
|
||||
],
|
||||
"w": [
|
||||
35
|
||||
],
|
||||
"x": [
|
||||
36
|
||||
],
|
||||
"y": [
|
||||
37
|
||||
],
|
||||
"z": [
|
||||
38
|
||||
],
|
||||
"æ": [
|
||||
39
|
||||
],
|
||||
"ç": [
|
||||
40
|
||||
],
|
||||
"ð": [
|
||||
41
|
||||
],
|
||||
"ø": [
|
||||
42
|
||||
],
|
||||
"ħ": [
|
||||
43
|
||||
],
|
||||
"ŋ": [
|
||||
44
|
||||
],
|
||||
"œ": [
|
||||
45
|
||||
],
|
||||
"ǀ": [
|
||||
46
|
||||
],
|
||||
"ǁ": [
|
||||
47
|
||||
],
|
||||
"ǂ": [
|
||||
48
|
||||
],
|
||||
"ǃ": [
|
||||
49
|
||||
],
|
||||
"ɐ": [
|
||||
50
|
||||
],
|
||||
"ɑ": [
|
||||
51
|
||||
],
|
||||
"ɒ": [
|
||||
52
|
||||
],
|
||||
"ɓ": [
|
||||
53
|
||||
],
|
||||
"ɔ": [
|
||||
54
|
||||
],
|
||||
"ɕ": [
|
||||
55
|
||||
],
|
||||
"ɖ": [
|
||||
56
|
||||
],
|
||||
"ɗ": [
|
||||
57
|
||||
],
|
||||
"ɘ": [
|
||||
58
|
||||
],
|
||||
"ə": [
|
||||
59
|
||||
],
|
||||
"ɚ": [
|
||||
60
|
||||
],
|
||||
"ɛ": [
|
||||
61
|
||||
],
|
||||
"ɜ": [
|
||||
62
|
||||
],
|
||||
"ɞ": [
|
||||
63
|
||||
],
|
||||
"ɟ": [
|
||||
64
|
||||
],
|
||||
"ɠ": [
|
||||
65
|
||||
],
|
||||
"ɡ": [
|
||||
66
|
||||
],
|
||||
"ɢ": [
|
||||
67
|
||||
],
|
||||
"ɣ": [
|
||||
68
|
||||
],
|
||||
"ɤ": [
|
||||
69
|
||||
],
|
||||
"ɥ": [
|
||||
70
|
||||
],
|
||||
"ɦ": [
|
||||
71
|
||||
],
|
||||
"ɧ": [
|
||||
72
|
||||
],
|
||||
"ɨ": [
|
||||
73
|
||||
],
|
||||
"ɪ": [
|
||||
74
|
||||
],
|
||||
"ɫ": [
|
||||
75
|
||||
],
|
||||
"ɬ": [
|
||||
76
|
||||
],
|
||||
"ɭ": [
|
||||
77
|
||||
],
|
||||
"ɮ": [
|
||||
78
|
||||
],
|
||||
"ɯ": [
|
||||
79
|
||||
],
|
||||
"ɰ": [
|
||||
80
|
||||
],
|
||||
"ɱ": [
|
||||
81
|
||||
],
|
||||
"ɲ": [
|
||||
82
|
||||
],
|
||||
"ɳ": [
|
||||
83
|
||||
],
|
||||
"ɴ": [
|
||||
84
|
||||
],
|
||||
"ɵ": [
|
||||
85
|
||||
],
|
||||
"ɶ": [
|
||||
86
|
||||
],
|
||||
"ɸ": [
|
||||
87
|
||||
],
|
||||
"ɹ": [
|
||||
88
|
||||
],
|
||||
"ɺ": [
|
||||
89
|
||||
],
|
||||
"ɻ": [
|
||||
90
|
||||
],
|
||||
"ɽ": [
|
||||
91
|
||||
],
|
||||
"ɾ": [
|
||||
92
|
||||
],
|
||||
"ʀ": [
|
||||
93
|
||||
],
|
||||
"ʁ": [
|
||||
94
|
||||
],
|
||||
"ʂ": [
|
||||
95
|
||||
],
|
||||
"ʃ": [
|
||||
96
|
||||
],
|
||||
"ʄ": [
|
||||
97
|
||||
],
|
||||
"ʈ": [
|
||||
98
|
||||
],
|
||||
"ʉ": [
|
||||
99
|
||||
],
|
||||
"ʊ": [
|
||||
100
|
||||
],
|
||||
"ʋ": [
|
||||
101
|
||||
],
|
||||
"ʌ": [
|
||||
102
|
||||
],
|
||||
"ʍ": [
|
||||
103
|
||||
],
|
||||
"ʎ": [
|
||||
104
|
||||
],
|
||||
"ʏ": [
|
||||
105
|
||||
],
|
||||
"ʐ": [
|
||||
106
|
||||
],
|
||||
"ʑ": [
|
||||
107
|
||||
],
|
||||
"ʒ": [
|
||||
108
|
||||
],
|
||||
"ʔ": [
|
||||
109
|
||||
],
|
||||
"ʕ": [
|
||||
110
|
||||
],
|
||||
"ʘ": [
|
||||
111
|
||||
],
|
||||
"ʙ": [
|
||||
112
|
||||
],
|
||||
"ʛ": [
|
||||
113
|
||||
],
|
||||
"ʜ": [
|
||||
114
|
||||
],
|
||||
"ʝ": [
|
||||
115
|
||||
],
|
||||
"ʟ": [
|
||||
116
|
||||
],
|
||||
"ʡ": [
|
||||
117
|
||||
],
|
||||
"ʢ": [
|
||||
118
|
||||
],
|
||||
"ʲ": [
|
||||
119
|
||||
],
|
||||
"ˈ": [
|
||||
120
|
||||
],
|
||||
"ˌ": [
|
||||
121
|
||||
],
|
||||
"ː": [
|
||||
122
|
||||
],
|
||||
"ˑ": [
|
||||
123
|
||||
],
|
||||
"˞": [
|
||||
124
|
||||
],
|
||||
"β": [
|
||||
125
|
||||
],
|
||||
"θ": [
|
||||
126
|
||||
],
|
||||
"χ": [
|
||||
127
|
||||
],
|
||||
"ᵻ": [
|
||||
128
|
||||
],
|
||||
"ⱱ": [
|
||||
129
|
||||
],
|
||||
"0": [
|
||||
130
|
||||
],
|
||||
"1": [
|
||||
131
|
||||
],
|
||||
"2": [
|
||||
132
|
||||
],
|
||||
"3": [
|
||||
133
|
||||
],
|
||||
"4": [
|
||||
134
|
||||
],
|
||||
"5": [
|
||||
135
|
||||
],
|
||||
"6": [
|
||||
136
|
||||
],
|
||||
"7": [
|
||||
137
|
||||
],
|
||||
"8": [
|
||||
138
|
||||
],
|
||||
"9": [
|
||||
139
|
||||
],
|
||||
"̧": [
|
||||
140
|
||||
],
|
||||
"̃": [
|
||||
141
|
||||
],
|
||||
"̪": [
|
||||
142
|
||||
],
|
||||
"̯": [
|
||||
143
|
||||
],
|
||||
"̩": [
|
||||
144
|
||||
],
|
||||
"ʰ": [
|
||||
145
|
||||
],
|
||||
"ˤ": [
|
||||
146
|
||||
],
|
||||
"ε": [
|
||||
147
|
||||
],
|
||||
"↓": [
|
||||
148
|
||||
],
|
||||
"#": [
|
||||
149
|
||||
],
|
||||
"\"": [
|
||||
150
|
||||
],
|
||||
"↑": [
|
||||
151
|
||||
],
|
||||
"̺": [
|
||||
152
|
||||
],
|
||||
"̻": [
|
||||
153
|
||||
]
|
||||
},
|
||||
"num_symbols": 256,
|
||||
"num_speakers": 1,
|
||||
"speaker_id_map": {},
|
||||
"piper_version": "1.0.0",
|
||||
"language": {
|
||||
"code": "ca_ES",
|
||||
"family": "ca",
|
||||
"region": "ES",
|
||||
"name_native": "Català",
|
||||
"name_english": "Catalan",
|
||||
"country_english": "Spain"
|
||||
},
|
||||
"dataset": "upc_ona"
|
||||
}
|
||||
5
backend/prompts/preamble.md
Normal file
5
backend/prompts/preamble.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Ets la QuiBot, un robot femení que ajuda als nens a aprendre sobre quimica. Disposes de dos rodes i dos braços.
|
||||
Has de ser educada i tenir perspectiva de gènere.
|
||||
Les teves respostes han de ser curtes.
|
||||
|
||||
|
||||
10
backend/run-mcp.sh
Executable file
10
backend/run-mcp.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
REMOTE_PORT=2223
|
||||
HOST=ollama.epsem.aranroig.com
|
||||
PORT=5001
|
||||
REMOTE_USER=root
|
||||
REMOTE_HOST=ollama.epsem.aranroig.com
|
||||
ssh -p ${REMOTE_PORT} -N -R ${HOST}:${PORT}:localhost:${PORT} -o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
${REMOTE_USER}@${REMOTE_HOST}
|
||||
56
backend/src/config.ts
Normal file
56
backend/src/config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
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;
|
||||
const piperUrl = process.env.PIPER_URL ?? '';
|
||||
const mcpUrl = process.env.MCP_URL ?? '';
|
||||
const llamacppUrl = process.env.LLAMA_CPP_URL ?? '';
|
||||
const llamacppApiKey = process.env.LLAMA_API_KEY ?? '';
|
||||
const llamaPreambleRaw = process.env.LLAMA_PREAMBLE ?? '';
|
||||
const llamacppPreamble = llamaPreambleRaw.endsWith('.md')
|
||||
? readFileSync(llamaPreambleRaw, 'utf-8')
|
||||
: llamaPreambleRaw;
|
||||
|
||||
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 getLlamacppUrl = () => llamacppUrl;
|
||||
export const getLlamacppApiKey = () => llamacppApiKey;
|
||||
export const getLlamacppPreamble = () => llamacppPreamble;
|
||||
|
||||
export const getPiperUrl = () => piperUrl;
|
||||
export const getPiperModelDir = () =>
|
||||
process.env.PIPER_MODELS_DIR || join('/tmp', 'quibot-piper-models');
|
||||
export const getPiperModel = () =>
|
||||
process.env.PIPER_MODEL ||
|
||||
join(getPiperModelDir(), 'ca_ES-upc_ona-medium.onnx');
|
||||
export const getMcpUrl = () => mcpUrl;
|
||||
|
||||
export const getAppPort = () => APP_PORT;
|
||||
124
backend/src/controllers/audio.controller.ts
Normal file
124
backend/src/controllers/audio.controller.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { rm, writeFile } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
import { whisperService } from '../services/whisper.service.js';
|
||||
import { raspiService } from '../services/raspi.service.js';
|
||||
import { llamacppService } from '../services/llama.service.js';
|
||||
const unlinkAsync = promisify(rm);
|
||||
const writeFileAsync = promisify(writeFile);
|
||||
|
||||
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}` });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload', upload.single('file'), async (req, res) => {
|
||||
let tmpFile: string | undefined;
|
||||
let tmpTxt: 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);
|
||||
|
||||
const transcription = await whisperService.transcribe(tmpFile);
|
||||
console.log(transcription);
|
||||
|
||||
const txtPath = join(tmpdir(), `quibot-audio-${Date.now()}.txt`);
|
||||
tmpTxt = txtPath;
|
||||
await writeFileAsync(txtPath, transcription);
|
||||
|
||||
const llmResponse = await llamacppService.chatWithMcpTools(transcription).catch(
|
||||
(err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[audio] llama.cpp failed: ${msg}`);
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
transcription,
|
||||
llmResponse,
|
||||
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
|
||||
}
|
||||
}
|
||||
if (tmpTxt) {
|
||||
try {
|
||||
await unlinkAsync(tmpTxt);
|
||||
} 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;
|
||||
52
backend/src/controllers/tts.controller.ts
Normal file
52
backend/src/controllers/tts.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Router } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { piperService } from '../services/piper.service.js';
|
||||
import { getToken } from '../config.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const TTS_AUDIO_DIR = join('/tmp', 'quibot-audio', 'tts');
|
||||
try { mkdirSync(TTS_AUDIO_DIR, { recursive: true }); } catch { /* ignore */ }
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const text = (req.query.text as string) || (req.body?.text as string);
|
||||
// Accept 'lang' or 'language' — APK sends 'language', old tests use 'lang'
|
||||
const lang = (req.query.lang as string) || (req.query.language as string) || 'ca';
|
||||
const token = (req.query.token as string) || '';
|
||||
|
||||
if (!text?.trim()) {
|
||||
return res.status(400).json({ error: 'Missing query parameter: text' });
|
||||
}
|
||||
|
||||
const expectedToken = getToken();
|
||||
if (token && token !== expectedToken) {
|
||||
return res.status(401).json({ error: 'Unauthorized: invalid token' });
|
||||
}
|
||||
|
||||
console.log(`[tts] Generating audio for text (${lang}): "${text.substring(0, 60)}..."`);
|
||||
|
||||
// Ensure Piper subprocess is initialized before synthesis
|
||||
await piperService.initWav();
|
||||
|
||||
const wavBuffer = await piperService.synthWav(text.trim());
|
||||
|
||||
const filename = `${randomUUID()}.wav`;
|
||||
writeFileSync(join(TTS_AUDIO_DIR, filename), wavBuffer);
|
||||
|
||||
console.log(`[tts] Audio saved: ${filename}`);
|
||||
|
||||
return res.json({
|
||||
audioUrl: `/tts-audio/${filename}`,
|
||||
filename,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error(`[tts] Error: ${message}`);
|
||||
return res.status(500).json({ error: `TTS synthesis failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
47
backend/src/index.ts
Normal file
47
backend/src/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import router from './routes/router.js';
|
||||
import { getAppPort, getConfig } from './config.js';
|
||||
import { whisperService } from './services/whisper.service.js';
|
||||
import { piperService as piperWorker } from './services/piper.service.js';
|
||||
import { mcpClient } from './services/mcp.service.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());
|
||||
|
||||
// Serve generated TTS audio files to the APK
|
||||
app.use('/tts-audio', express.static('/tmp/quibot-audio/tts'));
|
||||
|
||||
app.use(router);
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
const settings = getConfig();
|
||||
res.json({ status: 'ok', settings });
|
||||
});
|
||||
|
||||
const server = app.listen(getAppPort(), async () => {
|
||||
console.log(`QuiBot backend listening on port ${getAppPort()}`);
|
||||
whisperService.spawn();
|
||||
piperWorker.initWav().catch(() => { /* model may not exist yet → lazy init on first TTS call */ });
|
||||
mcpClient.connect().catch((err) => {
|
||||
console.error(`[mcp] Failed to start MCP client: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
});
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[server] ${signal} received, shutting down...`);
|
||||
server.close(async () => {
|
||||
await Promise.all([whisperService.shutdown(), piperWorker.shutdown(), mcpClient.shutdown()]);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
120
backend/src/piper-worker.py
Normal file
120
backend/src/piper-worker.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent Piper TTS worker – single subprocess, model loaded once."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import wave
|
||||
import io
|
||||
|
||||
|
||||
class PiperStdoutSink:
|
||||
"""Wraps stdout so we write exactly N bytes."""
|
||||
|
||||
def __init__(self):
|
||||
self._remaining = 0
|
||||
|
||||
def set_length(self, length: int):
|
||||
self._remaining = length
|
||||
|
||||
def write(self, data: bytes):
|
||||
to_write = min(len(data), self._remaining)
|
||||
if to_write > 0:
|
||||
sys.stdout.buffer.write(data[:to_write])
|
||||
sys.stdout.buffer.flush()
|
||||
self._remaining -= to_write
|
||||
|
||||
|
||||
def main():
|
||||
from piper import PiperVoice
|
||||
|
||||
model_path = ""
|
||||
config_path = None
|
||||
voice = None
|
||||
|
||||
# Defaults (will be overridden by init message)
|
||||
_default_dir = os.environ.get("PIPER_MODELS_DIR", "/tmp/quibot-piper-models")
|
||||
DEFAULT_MODEL = os.path.join(_default_dir, "ca_ES-upc_ona-medium.onnx")
|
||||
DEFAULT_CONFIG = os.path.join(_default_dir, "ca_ES-upc_ona-medium.onnx.json")
|
||||
|
||||
# Signal node that the process is alive and listening
|
||||
print(json.dumps({"type": "ready"}), flush=True)
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if msg.get("type") == "init":
|
||||
model_path = msg.get("model", DEFAULT_MODEL) or DEFAULT_MODEL
|
||||
config_path = msg.get("config", None)
|
||||
print(f"[piper-worker] Loading model='{model_path}'", file=sys.stderr, flush=True)
|
||||
try:
|
||||
voice = PiperVoice.load(model_path, config_path=config_path)
|
||||
print(json.dumps({"type": "init_ok"}), flush=True)
|
||||
except Exception as exc:
|
||||
err_msg = str(exc).replace('"', '\\"')
|
||||
print(json.dumps({"type": "init_error", "error": err_msg}), flush=True)
|
||||
|
||||
elif msg.get("type") == "synthesize":
|
||||
text = msg.get("text", "")
|
||||
msg_id = msg.get("msgId", "")
|
||||
|
||||
# NEW: output file path from message
|
||||
out_path = msg.get("outPath")
|
||||
|
||||
if not voice:
|
||||
print(json.dumps({
|
||||
"type": "error",
|
||||
"text": "Model not loaded, send init first",
|
||||
"msgId": msg_id,
|
||||
}), flush=True)
|
||||
continue
|
||||
|
||||
if not out_path:
|
||||
print(json.dumps({
|
||||
"type": "error",
|
||||
"text": "Missing outPath",
|
||||
"msgId": msg_id,
|
||||
}), flush=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
import io
|
||||
import wave
|
||||
|
||||
buf = io.BytesIO()
|
||||
wf = wave.open(buf, 'wb')
|
||||
|
||||
voice.synthesize_wav(text, wf)
|
||||
wf.close()
|
||||
|
||||
wav_bytes = buf.getvalue()
|
||||
|
||||
# Write to file instead of stdout
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(wav_bytes)
|
||||
|
||||
print(json.dumps({
|
||||
"type": "synthesized",
|
||||
"bytes": len(wav_bytes),
|
||||
"msgId": msg_id,
|
||||
"outPath": out_path
|
||||
}), flush=True)
|
||||
|
||||
except Exception as exc:
|
||||
err_msg = str(exc).replace('"', '\\"')
|
||||
print(json.dumps({
|
||||
"type": "error",
|
||||
"text": err_msg,
|
||||
"msgId": msg_id,
|
||||
}), flush=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
backend/src/routes/router.ts
Normal file
16
backend/src/routes/router.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import motorController from '../controllers/motor.controller.js';
|
||||
import audioController from '../controllers/audio.controller.js';
|
||||
import commandController from '../controllers/command.controller.js';
|
||||
import settingsController from '../controllers/settings.controller.js';
|
||||
import ttsController from '../controllers/tts.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/motor', motorController);
|
||||
router.use('/audio', audioController);
|
||||
router.use('/commands', commandController);
|
||||
router.use('/settings', settingsController);
|
||||
router.use('/tts', ttsController);
|
||||
|
||||
export default router;
|
||||
171
backend/src/services/llama.service.ts
Normal file
171
backend/src/services/llama.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { getLlamacppUrl, getLlamacppApiKey, getLlamacppPreamble } from '../config.js';
|
||||
import { mcpClient, McpToolDef } from './mcp.service.js';
|
||||
|
||||
interface LlamaMessage {
|
||||
role: string;
|
||||
content?: string | null;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
interface LlamaToolCallResult {
|
||||
content: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
}>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface LlamaToolDefinition {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: object;
|
||||
};
|
||||
}
|
||||
|
||||
interface LlamaRequest {
|
||||
messages: LlamaMessage[];
|
||||
tools?: LlamaToolDefinition[];
|
||||
tool_choice?: 'auto' | 'none';
|
||||
}
|
||||
|
||||
interface LlamaResponseChoice {
|
||||
message?: {
|
||||
content?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LlamaResponse {
|
||||
choices?: LlamaResponseChoice[];
|
||||
}
|
||||
|
||||
const MAX_TOOL_ITERATIONS = 10;
|
||||
|
||||
export const llamacppService = {
|
||||
async chat(messages: Array<{ role: string; content: string }>): Promise<string> {
|
||||
let history: LlamaMessage[] = messages.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
const apiUrl = getLlamacppUrl();
|
||||
if (!apiUrl) return '';
|
||||
|
||||
const apiKey = getLlamacppApiKey();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
|
||||
const request: LlamaRequest = { messages: history };
|
||||
const res = await fetch(apiUrl, { method: 'POST', headers, body: JSON.stringify(request) });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`llama.cpp request failed (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LlamaResponse;
|
||||
const choice = data.choices?.[0];
|
||||
|
||||
if (!choice?.message || !choice.message.content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return choice.message.content.trim();
|
||||
},
|
||||
|
||||
async chatWithPreamble(userText: string): Promise<string> {
|
||||
const preamble = getLlamacppPreamble();
|
||||
const msgs = preamble
|
||||
? [{ role: 'system' as const, content: preamble }, { role: 'user' as const, content: userText }]
|
||||
: [{ role: 'user' as const, content: userText }];
|
||||
return this.chat(msgs);
|
||||
},
|
||||
|
||||
async chatWithMcpTools(userText: string): Promise<string> {
|
||||
const preamble = getLlamacppPreamble();
|
||||
const initialMessages: LlamaMessage[] = preamble
|
||||
? [{ role: 'system', content: preamble }, { role: 'user', content: userText }]
|
||||
: [{ role: 'user', content: userText }];
|
||||
|
||||
if (!mcpClient.getReady()) {
|
||||
console.log('[llama] MCP not ready, falling back to preamble-only chat');
|
||||
return this.chatWithPreamble(userText);
|
||||
}
|
||||
|
||||
const tools = mcpClient.getTools();
|
||||
const llamaTools: LlamaToolDefinition[] = tools.map((t) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.inputSchema,
|
||||
},
|
||||
}));
|
||||
|
||||
return this._chatWithTools(initialMessages, llamaTools);
|
||||
},
|
||||
|
||||
async _chatWithTools(messages: LlamaMessage[], tools: LlamaToolDefinition[]): Promise<string> {
|
||||
const apiUrl = getLlamacppUrl();
|
||||
if (!apiUrl) return '';
|
||||
|
||||
let iter = 0;
|
||||
const history: LlamaMessage[] = messages.map((m) => ({ ...m }));
|
||||
|
||||
while (iter < MAX_TOOL_ITERATIONS) {
|
||||
const apiKey = getLlamacppApiKey();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
|
||||
const body: LlamaRequest = { messages: history, tools, tool_choice: 'auto' };
|
||||
const res = await fetch(apiUrl, { method: 'POST', headers, body: JSON.stringify(body) });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`llama.cpp request failed (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LlamaResponse;
|
||||
const choice = data.choices?.[0];
|
||||
|
||||
if (!choice?.message) {
|
||||
throw new Error('llama.cpp response has no message');
|
||||
}
|
||||
|
||||
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
|
||||
return choice.message.content?.trim() ?? '';
|
||||
}
|
||||
|
||||
history.push({ role: 'assistant', ...choice.message });
|
||||
|
||||
for (const toolCall of choice.message.tool_calls) {
|
||||
let resultText = '';
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments);
|
||||
resultText = await mcpClient.callTool(toolCall.function.name, args);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
resultText = `Error calling tool "${toolCall.function.name}": ${msg}`;
|
||||
}
|
||||
|
||||
history.push({
|
||||
role: 'tool',
|
||||
content: resultText,
|
||||
tool_call_id: toolCall.id,
|
||||
});
|
||||
}
|
||||
|
||||
iter++;
|
||||
}
|
||||
|
||||
throw new Error(`Exceeded max tool-call iterations (${MAX_TOOL_ITERATIONS})`);
|
||||
},
|
||||
};
|
||||
105
backend/src/services/mcp.service.ts
Normal file
105
backend/src/services/mcp.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { getMcpUrl } from '../config.js';
|
||||
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: object;
|
||||
}
|
||||
|
||||
let mc: Client | null = null;
|
||||
let cachedTools: McpToolDef[] = [];
|
||||
let connected = false;
|
||||
let connecting = false;
|
||||
|
||||
async function connectInternal(): Promise<void> {
|
||||
if (connected) return;
|
||||
if (connecting) throw new Error('MCP client connection already in progress');
|
||||
connecting = true;
|
||||
|
||||
const rawUrl = getMcpUrl();
|
||||
if (!rawUrl) {
|
||||
console.warn('[mcp] MCP_URL not configured, tools disabled');
|
||||
connecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the URL points at the /sse endpoint (FastMCP default)
|
||||
let connectUrl = rawUrl;
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
if (u.pathname === '/' || u.pathname === '') {
|
||||
u.pathname = '/sse';
|
||||
connectUrl = u.toString();
|
||||
}
|
||||
} catch {
|
||||
// not a valid URL, use as-is
|
||||
}
|
||||
|
||||
console.log(`[mcp] Connecting to ${connectUrl}...`);
|
||||
|
||||
try {
|
||||
mc = new Client(
|
||||
{ name: 'quibot-backend', version: '1.0.0' },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
const transport = new SSEClientTransport(new URL(connectUrl));
|
||||
await mc.connect(transport);
|
||||
|
||||
console.log('[mcp] Connected, listing tools...');
|
||||
const toolsResult = await mc.listTools();
|
||||
cachedTools = (toolsResult.tools ?? []).map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
inputSchema: t.inputSchema as object,
|
||||
}));
|
||||
|
||||
connected = true;
|
||||
connecting = false;
|
||||
console.log(`[mcp] Connected to MCP server with ${cachedTools.length} tool(s): ${cachedTools.map((t) => t.name).join(', ') || '(none)'}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
connecting = false;
|
||||
console.error(`[mcp] Connection failed: ${msg}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpClient = {
|
||||
async connect(): Promise<void> {
|
||||
await connectInternal();
|
||||
},
|
||||
|
||||
getReady(): boolean {
|
||||
return connected;
|
||||
},
|
||||
|
||||
getTools(): readonly McpToolDef[] {
|
||||
return cachedTools;
|
||||
},
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<string> {
|
||||
if (!mc) throw new Error('MCP client not connected');
|
||||
const result = await mc.callTool({ name, arguments: args });
|
||||
const content = result.content as Array<{ type: string; text?: string }>;
|
||||
const texts = content
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text ?? '');
|
||||
return texts.join('\n') || '[MCP tool returned no text content]';
|
||||
},
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (mc) {
|
||||
try {
|
||||
await mc.close();
|
||||
} catch {
|
||||
// ignore close errors on shutdown
|
||||
}
|
||||
mc = null;
|
||||
}
|
||||
connected = false;
|
||||
cachedTools = [];
|
||||
},
|
||||
};
|
||||
26
backend/src/services/piper.http.service.ts
Normal file
26
backend/src/services/piper.http.service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'axios';
|
||||
import { getPiperUrl } from '../config.js';
|
||||
|
||||
export interface PiperSynthesisParams {
|
||||
text: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
class PiperHttpService {
|
||||
async synthesize(params: PiperSynthesisParams): Promise<Buffer> {
|
||||
const piperUrl = getPiperUrl();
|
||||
if (!piperUrl) throw new Error('PIPER_URL not configured');
|
||||
|
||||
const speakerId = params.lang === 'en' ? 1 : 0;
|
||||
|
||||
const response = await axios.post(
|
||||
`${piperUrl}/api/tts`,
|
||||
{ text: params.text, speaker_id: speakerId },
|
||||
{ responseType: 'arraybuffer', timeout: 30_000 },
|
||||
);
|
||||
|
||||
return Buffer.from(response.data, 'binary');
|
||||
}
|
||||
}
|
||||
|
||||
export const piperService = new PiperHttpService();
|
||||
285
backend/src/services/piper.service.ts
Normal file
285
backend/src/services/piper.service.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFileSync, rmSync, readdirSync, statSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { piperService as httpPiperService } from './piper.http.service.js';
|
||||
import { getPiperUrl, getPiperModel } from '../config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = join(__filename, '..');
|
||||
const SCRIPT_DIR = join(__dirname, '..');
|
||||
|
||||
const TTS_DIR = join('/tmp', 'quibot-audio', 'tts-piper');
|
||||
mkdirSync(TTS_DIR, { recursive: true });
|
||||
|
||||
// ─── type-guard for JSON messages from piper-worker ───
|
||||
type PiperMsg =
|
||||
| { type: 'ready' }
|
||||
| { type: 'init_ok' }
|
||||
| { type: 'init_error'; error: string }
|
||||
| { type: 'synthesized'; outPath: string; bytes?: number; msgId: string }
|
||||
| { type: 'error'; text: string; msgId: string };
|
||||
|
||||
class PiperLocalService {
|
||||
private proc: ChildProcess | null = null;
|
||||
private pendingInit: Promise<void> | null = null;
|
||||
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── spawn + stdout parser (simple: pure JSON on stdout, WAV on disk) ──
|
||||
|
||||
private setupStdout(): void {
|
||||
if (!this.proc?.stdout) return;
|
||||
let buf = '';
|
||||
this.proc.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
while (true) {
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl === -1) break;
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
this.handleLine(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
console.log('[RX]', line);
|
||||
let msg: PiperMsg;
|
||||
|
||||
try {
|
||||
msg = JSON.parse(line) as PiperMsg;
|
||||
} catch {
|
||||
console.warn('[piper-svc] Invalid JSON:', line);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
break;
|
||||
|
||||
case 'init_ok':
|
||||
this.resolveInit();
|
||||
break;
|
||||
|
||||
case 'init_error':
|
||||
this.resolveInitError(new Error(msg.error));
|
||||
break;
|
||||
|
||||
case 'synthesized':
|
||||
this.resolveResponse(msg.msgId, msg.outPath);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.rejectResponse(
|
||||
msg.msgId,
|
||||
new Error(msg.text)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeStdin(line: string): Promise<void> {
|
||||
if (!this.proc?.stdin) {
|
||||
throw new Error('piper-svc: stdin unavailable');
|
||||
}
|
||||
|
||||
console.log('[TX]', line);
|
||||
|
||||
this.proc.stdin.write(line + '\n');
|
||||
}
|
||||
|
||||
// ── pending-init promises (separate from synth to avoid clearing respMap on init failure) ──
|
||||
private initResolve: (() => void) | null = null;
|
||||
private initReject: ((e: Error) => void) | null = null;
|
||||
|
||||
private resolveInit(): void {
|
||||
if (!this.pendingInit) return;
|
||||
|
||||
this.initResolve?.();
|
||||
|
||||
this.pendingInit = null;
|
||||
this.initResolve = null;
|
||||
this.initReject = null;
|
||||
}
|
||||
|
||||
private rejectResponse(msgId: string, err: Error): void {
|
||||
const entry = this.respMap.get(msgId);
|
||||
|
||||
this.respMap.delete(msgId);
|
||||
|
||||
if (entry) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveInitError(err: Error): void {
|
||||
if (!this.pendingInit) return;
|
||||
|
||||
this.initReject?.(err);
|
||||
|
||||
this.pendingInit = null;
|
||||
this.initResolve = null;
|
||||
this.initReject = null;
|
||||
}
|
||||
|
||||
// ── pending synth responses (separate from init so init failure doesn't clear them) ──
|
||||
private respMap = new Map<string, {
|
||||
resolve: (wavPath: string) => void;
|
||||
reject: (e: Error) => void;
|
||||
}>();
|
||||
|
||||
private resolveResponse(msgId: string, wavPath: string): void {
|
||||
const entry = this.respMap.get(msgId);
|
||||
this.respMap.delete(msgId);
|
||||
if (entry?.resolve) entry.resolve(wavPath);
|
||||
}
|
||||
|
||||
// ── public spawn / initWav / synthWav ──
|
||||
|
||||
private async _spawn(): Promise<void> {
|
||||
if (this.proc) return;
|
||||
|
||||
const workerPath = join(SCRIPT_DIR, 'piper-worker.py');
|
||||
const venv = process.env.VIRTUAL_ENV || join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
|
||||
this.proc = spawn(venv, [workerPath], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
this.setupStdout();
|
||||
|
||||
if (this.proc.stderr) {
|
||||
this.proc.stderr.on('data', (chunk: Buffer) => {
|
||||
console.error(
|
||||
'[piper-worker]',
|
||||
chunk.toString().trim()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.proc.on('exit', () => {
|
||||
console.log('[piper-svc] Process exited, rejecting all pending synths');
|
||||
// reject all pending (new format: {resolve, reject})
|
||||
for (const [, entry] of this.respMap) entry.reject(new Error('piper process exited'));
|
||||
this.respMap.clear();
|
||||
if (this.pendingInit && this.initReject) {
|
||||
this.initReject(new Error('piper process exited'));
|
||||
this.pendingInit = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ── cleanup old WAV files every 5 min ──
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
for (const entry of readdirSync(TTS_DIR)) {
|
||||
const fp = join(TTS_DIR, entry);
|
||||
try {
|
||||
const s = statSync(fp);
|
||||
if (s.isFile() && now - s.mtimeMs > 300_000) unlinkSync(fp);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
async initWav(): Promise<void> {
|
||||
if (!this.proc) await this._spawn();
|
||||
if (this.pendingInit) return this.pendingInit;
|
||||
|
||||
this.pendingInit = new Promise<void>((resolve, reject) => {
|
||||
this.initResolve = resolve;
|
||||
this.initReject = reject;
|
||||
});
|
||||
const modelPath = getPiperModel() || join('/tmp', 'quibot-piper-models', 'ca_ES-upc_ona-medium.onnx');
|
||||
const cfgPath = modelPath.replace(/\.onnx$/, '.onnx.json');
|
||||
await this.writeStdin(
|
||||
JSON.stringify({ type: 'init', model: modelPath, config: cfgPath }),
|
||||
);
|
||||
return this.pendingInit;
|
||||
}
|
||||
|
||||
/** Synthesize with local Piper subprocess (primary) and HTTP Piper fallback */
|
||||
async synthesize(params: { text: string }): Promise<Buffer> {
|
||||
try {
|
||||
await this.initWav();
|
||||
return await this.synthWav(params.text);
|
||||
} catch (localErr) {
|
||||
const url = getPiperUrl();
|
||||
if (url) {
|
||||
console.log(`[tts] Local Piper failed: ${localErr instanceof Error ? localErr.message : localErr}. Falling back to remote.`);
|
||||
return await httpPiperService.synthesize({ ...params, lang: 'ca' });
|
||||
}
|
||||
throw localErr;
|
||||
}
|
||||
}
|
||||
|
||||
async synthWav(text: string): Promise<Buffer> {
|
||||
await this.initWav();
|
||||
if (!this.proc) await this._spawn(); // auto-spawn; init runs concurrently
|
||||
const msgId = randomUUID() + '-' + Date.now();
|
||||
const outPath = join(TTS_DIR, `${msgId}.wav`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let cleared = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
this.respMap.delete(msgId);
|
||||
reject(new Error('piper-svc: synthesis timed out after 120s'));
|
||||
}, 120_000);
|
||||
|
||||
this.respMap.set(msgId, {
|
||||
resolve: (wavPath: string) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
console.log(`[piper-svc] Synthesized ${wavPath} (${Date.now()})`);
|
||||
try {
|
||||
const buf = readFileSync(wavPath);
|
||||
rmSync(wavPath, { force: true });
|
||||
resolve(buf);
|
||||
} catch (err: unknown) {
|
||||
reject(err instanceof Error ? err : new Error('read WAV failed'));
|
||||
}
|
||||
},
|
||||
reject: (e: Error) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
this.respMap.delete(msgId);
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[piper-svc] synthesize ${text.substring(0, 40)}... (msgId=${msgId})`);
|
||||
this.writeStdin(
|
||||
JSON.stringify({ type: 'synthesize', text, msgId, outPath }),
|
||||
).catch((e) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
this.respMap.delete(msgId);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── shutdown ──
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
}
|
||||
if (!this.proc) return;
|
||||
const p = this.proc; this.proc = null;
|
||||
await new Promise<void>((res) => {
|
||||
p.on('exit', res);
|
||||
setTimeout(res, 3000);
|
||||
if (!p.killed) { try { p.stdin?.end(); } catch {} p.kill('SIGTERM'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── singleton ────────────────────────────────────────────────
|
||||
|
||||
export const piperService = new PiperLocalService();
|
||||
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;
|
||||
},
|
||||
};
|
||||
218
backend/src/services/whisper.service.ts
Normal file
218
backend/src/services/whisper.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = join(__filename, '..');
|
||||
|
||||
const SCRIPT_DIR = join(__dirname, '..');
|
||||
|
||||
const PYTHON = join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
|
||||
|
||||
const whisperModel = process.env.WHISPER_MODEL ?? 'small';
|
||||
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
|
||||
|
||||
interface TranscriptResult {
|
||||
msgId: string;
|
||||
text?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface InitResult {
|
||||
type: 'init_ok' | 'init_error';
|
||||
}
|
||||
|
||||
class WhisperService {
|
||||
private proc: ChildProcess | null = null;
|
||||
private onInitResolve: (() => void) | null = null;
|
||||
private onInitReject: ((err: Error) => void) | null = null;
|
||||
|
||||
spawn(): void {
|
||||
if (this.proc) return;
|
||||
|
||||
const scriptPath = join(SCRIPT_DIR, 'whisper-worker.py');
|
||||
|
||||
this.proc = spawn(PYTHON, [scriptPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
if (!this.proc.stdout || !this.proc.stderr || !this.proc.stdin) {
|
||||
console.error('[whisper-svc] Missing stdin/stdout/stderr');
|
||||
this.proc = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const proc = this.proc;
|
||||
if (!proc?.stdout) return;
|
||||
let buf = '';
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
while (true) {
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl === -1) break;
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.type === 'ready') {
|
||||
console.log('[whisper-svc] Worker ready, sending init...');
|
||||
proc.stdin!.write(
|
||||
JSON.stringify({ type: 'init', model: whisperModel, language: whisperLanguage }) + '\n',
|
||||
);
|
||||
} else if (msg.type === 'init_ok') {
|
||||
console.log(`[whisper-svc] Model loaded (model=${whisperModel}, lang=${whisperLanguage})`);
|
||||
if (this.onInitResolve) {
|
||||
const r = this.onInitResolve;
|
||||
this.onInitResolve = null;
|
||||
this.onInitReject = null;
|
||||
r();
|
||||
}
|
||||
} else if (msg.type === 'init_error') {
|
||||
const err = new Error(`whisper-svc init failed: ${msg.error || 'unknown'}`);
|
||||
if (this.onInitReject) {
|
||||
const r = this.onInitReject;
|
||||
this.onInitResolve = null;
|
||||
this.onInitReject = null;
|
||||
r(err);
|
||||
}
|
||||
} else if (msg.type === 'transcript' || msg.type === 'error') {
|
||||
this.resolveTranscript(msg.msgId, msg);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
});
|
||||
|
||||
const stderr = proc.stderr;
|
||||
if (stderr) {
|
||||
stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) console.log(`[whisper-svc] stderr: ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
console.log(`[whisper-svc] Exited code=${code} signal=${signal}`);
|
||||
this.proc = null;
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error(`[whisper-svc] Error: ${err.message}`);
|
||||
this.proc = null;
|
||||
});
|
||||
}
|
||||
|
||||
private pending: Map<string, (result: TranscriptResult) => void> = new Map();
|
||||
|
||||
private resolveTranscript(msgId: string, msg: { type?: string; text?: string; error?: string }) {
|
||||
const cb = this.pending.get(msgId);
|
||||
this.pending.delete(msgId);
|
||||
if (cb) {
|
||||
if (msg.type === 'error') {
|
||||
cb({
|
||||
msgId,
|
||||
text: msg.text,
|
||||
error: msg.error ?? msg.text ?? 'unknown error',
|
||||
});
|
||||
} else {
|
||||
cb({ msgId, text: msg.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private waitForInit(): Promise<void> {
|
||||
if (this.onInitResolve) return Promise.resolve(); // already initializing
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let cleared = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
this.onInitReject = null;
|
||||
reject(new Error('whisper-svc init timed out'));
|
||||
}, 90_000);
|
||||
this.onInitResolve = () => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
this.onInitReject = (err: Error) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async transcribe(audioPath: string): Promise<string> {
|
||||
if (!this.proc) {
|
||||
this.spawn();
|
||||
}
|
||||
|
||||
// await this.waitForInit();
|
||||
|
||||
const msgId = randomUUID() + '-' + Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let cleared = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const resolvePromise = (result: TranscriptResult) => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (result.error) {
|
||||
reject(new Error(`whisper-svc: ${result.error}`));
|
||||
} else if (result.text) {
|
||||
resolve(result.text.trim());
|
||||
} else {
|
||||
reject(new Error('whisper-svc: empty response'));
|
||||
}
|
||||
};
|
||||
|
||||
this.pending.set(msgId, resolvePromise);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
if (cleared) return;
|
||||
cleared = true;
|
||||
this.pending.delete(msgId);
|
||||
reject(new Error('whisper-svc: transcription timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const proc = this.proc;
|
||||
if (proc && proc.stdin) {
|
||||
proc.stdin.write(
|
||||
JSON.stringify({ type: 'transcribe', path: audioPath, msgId }) + '\n',
|
||||
);
|
||||
} else {
|
||||
cleared = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
this.pending.delete(msgId);
|
||||
reject(new Error('whisper subprocess not running'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const proc = this.proc;
|
||||
if (proc) {
|
||||
try {
|
||||
proc.stdin!.end();
|
||||
await new Promise<void>((resolve) => {
|
||||
proc.on('exit', () => resolve());
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) proc.kill('SIGTERM');
|
||||
resolve();
|
||||
}, 3000);
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const whisperService = new WhisperService();
|
||||
55
backend/src/whisper-worker.py
Normal file
55
backend/src/whisper-worker.py
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent Whisper transcription worker – single subprocess, model loaded once."""
|
||||
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
def main():
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
model_path = "base"
|
||||
language = "ca"
|
||||
model = None
|
||||
|
||||
# Signal node that the process is alive and listening
|
||||
print(json.dumps({"type": "ready"}), flush=True)
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if msg.get("type") == "init":
|
||||
model_path = msg.get("model", "base")
|
||||
language = msg.get("language", "ca") or "ca"
|
||||
print(f"[whisper-worker] Loading model='{model_path}' language='{language}'", file=sys.stderr, flush=True)
|
||||
model = WhisperModel(model_path, device="cpu", compute_type="int8")
|
||||
print(json.dumps({"type": "init_ok"}), flush=True)
|
||||
continue
|
||||
|
||||
if msg.get("type") == "transcribe":
|
||||
audio_path = msg.get("path")
|
||||
msg_id = msg.get("msgId", "")
|
||||
if not audio_path:
|
||||
print(json.dumps({"type": "error", "text": "no path provided", "msgId": msg_id}), flush=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
segments, info = model.transcribe(audio_path, language=language or None)
|
||||
transcript = ""
|
||||
for seg in segments:
|
||||
transcript += seg.text + " "
|
||||
result_text = transcript.strip()
|
||||
print(json.dumps({"type": "transcript", "text": result_text, "msgId": msg_id}), flush=True)
|
||||
except Exception as exc:
|
||||
print(json.dumps({"type": "error", "text": str(exc), "msgId": msg_id}), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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 |
1
mcp/.gitignore
vendored
Normal file
1
mcp/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.venv/
|
||||
46
mcp/mcp_server.py
Normal file
46
mcp/mcp_server.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastmcp import FastMCP
|
||||
import requests
|
||||
import threading
|
||||
|
||||
mcp = FastMCP("Test MCP server")
|
||||
|
||||
def fire_and_forget(url: str):
|
||||
try:
|
||||
requests.get(url, timeout=2)
|
||||
except Exception:
|
||||
pass # ignore all errors so it never affects the tool
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers together"""
|
||||
return a + b
|
||||
|
||||
@mcp.tool()
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someome by its name"""
|
||||
return f"Hello {name}! Welcome!"
|
||||
|
||||
@mcp.tool()
|
||||
def multiply(a: int, b: int) -> int:
|
||||
"""Multiply two numbers"""
|
||||
return a * b
|
||||
|
||||
@mcp.tool()
|
||||
def get_time() -> str:
|
||||
"""Get the current time"""
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%I:%M %p")
|
||||
|
||||
@mcp.tool()
|
||||
def moure_brac() -> str:
|
||||
"""Mou els braços"""
|
||||
|
||||
url = "http://quibot.local:8000/greet"
|
||||
|
||||
# start background request (non-blocking)
|
||||
threading.Thread(target=fire_and_forget, args=(url,), daemon=True).start()
|
||||
|
||||
return "Braços moguts"
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="sse", port=5001)
|
||||
399
package-lock.json
generated
Normal file
399
package-lock.json
generated
Normal file
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"name": "quibot",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"formdata-node": "^6.0.3",
|
||||
"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/formdata-node": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz",
|
||||
"integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
package.json
Normal file
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"formdata-node": "^6.0.3",
|
||||
"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: {
|
||||
quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000',
|
||||
quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN',
|
||||
public: {
|
||||
defaultLocale: 'en',
|
||||
supportedLocales: ['en', 'ca', 'es'],
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
|
||||
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": {
|
||||
"nuxt": "^4.4.2",
|
||||
"react-native-svg": "^15.15.5",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const direction = getRouterParam(event, 'direction')
|
||||
|
||||
if (direction !== 'forward' && direction !== 'backwards') {
|
||||
@@ -9,7 +8,10 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
return await $fetch(`${config.quibotBaseUrl}/motor/step/${direction}`, {
|
||||
const baseUrl = getPiBaseUrl(event)
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
return await $fetch(`${baseUrl}/motor/step/${direction}`, {
|
||||
method: 'POST',
|
||||
query: {
|
||||
token: config.quibotToken,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export default defineEventHandler(async () => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const baseUrl = getPiBaseUrl(event)
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
return await $fetch(`${config.quibotBaseUrl}/motor/stop`, {
|
||||
return await $fetch(`${baseUrl}/motor/stop`, {
|
||||
method: 'POST',
|
||||
query: {
|
||||
token: config.quibotToken,
|
||||
|
||||
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
|
||||
}
|
||||
81
rasp/server.py
Normal file
81
rasp/server.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Rasp'))
|
||||
|
||||
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()
|
||||
|
||||
def test_arms_pair():
|
||||
pi = _setup_motors()
|
||||
enable_arms(ON); time.sleep(0.1)
|
||||
motion.arm_R.move(+200); motion.arm_L.move(+200)
|
||||
time.sleep(1.5)
|
||||
motion.arm_R.move(-200); motion.arm_L.move(-200)
|
||||
time.sleep(0.5)
|
||||
enable_arms(OFF)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return jsonify({
|
||||
"message": "Flask API is running"
|
||||
})
|
||||
|
||||
@app.route("/greet", methods=["GET"])
|
||||
def greet():
|
||||
test_arms_pair()
|
||||
return jsonify({
|
||||
"message": "Hello, world!"
|
||||
})
|
||||
|
||||
|
||||
# Simple error handlers
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return jsonify({"error": "Route not found"}), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host="0.0.0.0", port=8000)
|
||||
Reference in New Issue
Block a user