30 Commits
latest ... main

Author SHA1 Message Date
c6ac77a8b2 Mes canvis
Some checks failed
Build / build-web (push) Failing after 19s
Build / build-backend (push) Successful in 9s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m42s
Build APK / release (push) Successful in 3s
2026-06-19 13:20:12 +02:00
23235e8249 Test
Some checks failed
Build / build-web (push) Failing after 15s
Build / build-backend (push) Successful in 10s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m28s
Build APK / release (push) Successful in 3s
2026-06-19 11:06:11 +02:00
63903e6f7e Test
Some checks failed
Build / build-web (push) Failing after 10s
Build / build-backend (push) Successful in 2s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m23s
Build APK / release (push) Successful in 3s
2026-06-19 09:36:40 +02:00
432df63298 Jkdsjksj
Some checks failed
Build / build-web (push) Failing after 17s
Build / build-backend (push) Successful in 2s
Build / release (push) Has been skipped
Build APK / build (push) Failing after 41s
Build APK / release (push) Has been skipped
2026-06-19 09:34:52 +02:00
023d3c04b9 jdksjkdsj
Some checks failed
Build / build-web (push) Failing after 16s
Build / build-backend (push) Successful in 2s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m39s
Build APK / release (push) Successful in 3s
2026-06-18 23:03:39 +02:00
f24cbd248d Debug
Some checks failed
Build / build-web (push) Failing after 19s
Build / build-backend (push) Successful in 2s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m38s
Build APK / release (push) Failing after 2s
2026-06-18 22:36:31 +02:00
bf13fdc33c Build apk
Some checks failed
Build / build-web (push) Failing after 10s
Build / build-backend (push) Successful in 2s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m51s
Build APK / release (push) Failing after 2s
2026-06-18 22:17:00 +02:00
8ae828fb6e Action 2
Some checks failed
Build / build-backend (push) Successful in 5s
Build / build-web (push) Failing after 19s
Build / release (push) Has been skipped
2026-06-18 22:15:22 +02:00
a16f31a331 jdskjkdj
Some checks failed
Build / build-web (push) Failing after 21s
Build / build-backend (push) Successful in 2s
Build / release (push) Has been skipped
Build APK / build (push) Successful in 8m55s
2026-06-18 21:47:33 +02:00
76a56d1a42 si 2026-06-18 21:46:44 +02:00
5086743a11 LLM connection 2026-06-18 21:16:28 +02:00
9a23863320 TTs whisper 2026-06-18 13:45:32 +02:00
0e7fbbfdca Things
All checks were successful
Build / build-web (push) Successful in 16s
Build / build-backend (push) Successful in 3s
Build / release (push) Successful in 3s
Build APK / build (push) Successful in 8m10s
2026-06-11 15:09:56 +02:00
152b541a6c Test
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 5s
2026-04-22 13:49:12 +02:00
44fe3361a3 Now working
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 5s
2026-04-22 13:41:39 +02:00
e43aee9b13 Wrong direction and also not enabled on boot
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 6s
2026-04-22 12:51:31 +02:00
d4c69e4b32 Frontend test
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 6s
2026-04-22 12:48:27 +02:00
28457eb200 Frontend test
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 5s
2026-04-22 12:45:29 +02:00
f1c530e5b1 Motor driver fix
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 5s
2026-04-22 12:34:26 +02:00
1e3198d09b Motor driver fix
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 5s
2026-04-22 12:31:15 +02:00
965d6dde63 Test Motor
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 6s
2026-04-22 12:19:49 +02:00
e5a65f2dcf TEst CI
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 7s
2026-04-22 11:54:16 +02:00
ad6d369dcf TEst CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 4s
2026-04-22 11:53:18 +02:00
5a4d0b9348 Test CI
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 4s
2026-04-22 11:49:20 +02:00
42979daaf2 Test CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 4s
2026-04-22 11:48:31 +02:00
f8172d9323 Test CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 4s
2026-04-22 11:46:02 +02:00
4256f65161 Test CI
Some checks failed
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Failing after 2s
2026-04-22 11:44:47 +02:00
c113139303 Test CI
Some checks failed
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 14s
Build / release (push) Failing after 1s
2026-04-22 11:43:05 +02:00
6ea0479059 Test CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 13s
2026-04-22 11:40:28 +02:00
0c673ab5f1 Test CI
Some checks failed
Build / build-backend (push) Failing after 4s
Build / build-web (push) Failing after 11s
Build / release (push) Has been skipped
2026-04-22 11:39:25 +02:00
79 changed files with 25000 additions and 82 deletions

0
.codex Normal file
View File

View File

@@ -0,0 +1,71 @@
name: Build APK
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./apk
steps:
- name: 📥 Checkout
uses: actions/checkout@v4
- name: 🟢 Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: 📦 Install dependencies
run: npm install
- name: ☕ Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: 📱 Setup Android SDK
uses: android-actions/setup-android@v3
- name: ⚙️ Expo prebuild
run: npx expo prebuild --clean --non-interactive
- name: 🔐 Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/release.keystore
- name: 🏗️ Build APK (Release)
working-directory: ./apk/android
run: |
./gradlew assembleRelease
- 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
with:
tag_name: latest
name: Latest Build
overwrite_files: true
files: |
dist/app-release.apk
env:
GITEA_TOKEN: ${{ secrets.GITEA }}

View File

@@ -1,12 +1,12 @@
name: Build Nuxt App
name: Build
on:
push:
branches:
- master
- main
jobs:
build:
build-web:
runs-on: docker
defaults:
@@ -43,12 +43,63 @@ jobs:
cd dist_package
zip -r ../quibot-web.zip .
# Create or update release and upload asset
- name: Upload Release
uses: softprops/action-gh-release@v1
- name: Upload Web artifact
uses: actions/upload-artifact@v3
with:
name: quibot-web
path: quibot-web/quibot-web.zip
build-backend:
runs-on: docker
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: |
apt update
apt install -y zip
- name: Create zip
run: |
zip -r backend.zip .
- name: Upload Backend artifact
uses: actions/upload-artifact@v3
with:
name: backend
path: backend/backend.zip
release:
runs-on: docker
needs: [build-web, build-backend]
steps:
- name: Download Web Artifact
uses: actions/download-artifact@v3
with:
name: quibot-web
path: dist
- name: Download Backend Artifact
uses: actions/download-artifact@v3
with:
name: backend
path: dist
- name: Create Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: latest
name: Latest Build
files: quibot-web/quibot-web.zip
overwrite_files: true
files: |
dist/quibot-web.zip
dist/backend.zip
env:
GITHUB_TOKEN: ${{ secrets.GITEATOKEN }}
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}

1
.gitignore vendored Normal file
View File

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

359
AGENTS.md Normal file
View 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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 quibot-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,75 +1,22 @@
# Nuxt Minimal Starter
# QuiBot-3Dnew
Documentació oficial i codi
Normes del repositori:
- Cada alumne és responsable del seu codi
- Sha de treballar en branques pròpies (opcional)
- No es pot modificar la carpeta daltres zones
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
# Estructura del projecte
## Setup
- 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.
Make sure to install dependencies:
# Instalació
```bash
# npm
npm install
Necesitem primer una raspberry operativa. Un cop operativa cal descarregar el codi que controla el robot. Es necesari tenir python.
# pnpm
pnpm install
- Cal passar la carpeta backend a la raspberry pi
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

BIN
UPCLogo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

43
apk/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

1
apk/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
apk/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

39
apk/README.md Normal file
View File

@@ -0,0 +1,39 @@
# VoiceDrop
Minimal Expo app for one job: record audio and send it to a backend endpoint.
## What It Does
- Requests microphone permission.
- Records a single audio clip on-device.
- Uploads that clip as `multipart/form-data`.
- Lets you configure the backend URL, bearer token, and form field name from the UI.
## Run It
```bash
npm install
npx expo start
```
If you build native apps after changing config/plugins, run prebuild again:
```bash
npx expo prebuild
```
## Backend Expectations
The app sends a `POST` request to the URL you enter with a multipart body containing one file field. By default the field name is `file`.
If you enter a bearer token, the request includes:
```txt
Authorization: Bearer <token>
```
## Notes
- `expo-av` is required for native recording.
- The backend URL and auth token are persisted locally with AsyncStorage.
- The current environment may require a fresh `npm install` before linting or running because the dependency manifest was simplified around the new app.

73
apk/app.json Normal file
View File

@@ -0,0 +1,73 @@
{
"expo": {
"name": "VoiceDrop",
"slug": "voice-drop",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "voicedrop",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.arandano69.voicedrop"
},
"android": {
"permissions": [
"RECORD_AUDIO"
],
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.arandano69.voicedrop"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-av",
{
"microphonePermission": "Allow VoiceDrop to use your microphone."
}
],
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "f761fcbd-46f2-4387-8282-005e44223075"
}
}
}
}

16
apk/app/_layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "#f4efe4" },
}}
/>
</>
);
}

835
apk/app/index.tsx Normal file
View File

@@ -0,0 +1,835 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import Svg, { Path } from "react-native-svg";
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { loadRecorderSettings } from "@/lib/recorder-settings";
import { getStrings, type Locale, t } from "@/lib/translations";
function formatDuration(durationMs: number) {
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
function buildMimeType(uri: string) {
const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase();
switch (extension) {
case "wav":
return "audio/wav";
case "caf":
return "audio/x-caf";
case "webm":
return "audio/webm";
case "mp3":
return "audio/mpeg";
default:
return "audio/m4a";
}
}
function buildFileExtension(uri: string) {
return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a";
}
export default function RecorderScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
const [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("");
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;
async function loadStoredValues() {
try {
const settings = await loadRecorderSettings();
if (!isMounted) {
return;
}
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLocale(settings.language);
setStrings(getStrings(settings.language));
} catch {
if (isMounted) {
setStatusMessage(strings.loadError);
}
}
}
void loadStoredValues();
return () => {
isMounted = false;
};
}, []);
useFocusEffect(refreshSettings);
useEffect(() => {
if (!recording) {
return;
}
const interval = setInterval(() => {
void recording.getStatusAsync().then((status) => {
if (typeof status.durationMillis === "number") {
setRecordingMs(status.durationMillis ?? 0);
}
});
}, 250);
return () => {
clearInterval(interval);
};
}, [recording]);
useEffect(() => {
return () => {
if (recording) {
void recording.stopAndUnloadAsync().catch(() => undefined);
}
};
}, [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(strings.micPermissionDenied);
Alert.alert(
strings.micAccessRequiredTitle,
strings.micAccessRequiredMsg,
);
return;
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
playsInSilentModeIOS: true,
shouldDuckAndroid: true,
staysActiveInBackground: false,
});
const result = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
recordingRef.current = result.recording;
setRecording(result.recording);
setRecordingMs(0);
setStatusMessage(strings.recording);
} catch (error) {
setStatusMessage(strings.couldNotStartRecording);
Alert.alert(
strings.recordingFailedTitle,
error instanceof Error ? error.message : "",
);
}
}
async function stopRecordingAndUpload() {
if (!recordingRef.current) {
return;
}
console.log("[APP] stopRecordingAndUpload called");
try {
const activeRecording = recordingRef.current;
const currentStatus = await activeRecording.getStatusAsync();
const durationMillis = currentStatus.durationMillis ?? 0;
await activeRecording.stopAndUnloadAsync();
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = activeRecording.getURI();
recordingRef.current = null;
setRecording(null);
setRecordingMs(durationMillis);
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) {
recordingRef.current = null;
setRecording(null);
setStatusMessage(strings.stopFailedTitle);
Alert.alert(
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;
if (!targetUri) {
return;
}
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (!uploadUrl) {
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
return;
}
try {
setIsUploading(true);
setStatusMessage(strings.uploadingRecording);
await unloadSound();
setTranscriptionText("");
setResponsePreview("");
setLlmResponseText("");
const mimeType = buildMimeType(targetUri);
const extension = buildFileExtension(targetUri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: targetUri,
} as never);
const headers: Record<string, string> = {};
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);
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(strings.uploadFailed);
Alert.alert(
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 (
<View style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<View style={styles.heroTopRow}>
<View style={styles.heroBadge}>
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
</View>
<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>
</View>
<View style={styles.panel}>
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
{formatDuration(recordingMs)}
</Text>
<Pressable
disabled={isUploading}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
styles.micButton,
isHolding ? styles.holdingButton : styles.idleButton,
isUploading && styles.buttonDisabled,
]}
>
{isUploading ? (
<ActivityIndicator color="#fff6f3" size="large" />
) : (
<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 || strings.readyToRecord}</Text>
<Text style={styles.helperText}>
{isHolding
? releaseLabel
: backendUrl.trim()
? holdLabel
: openSettingsLabel}
</Text>
{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}>{serverResponseLabel}</Text>
<Text style={styles.responseText}>{responsePreview}</Text>
</View>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: "#f4efe4",
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
content: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: 32,
paddingHorizontal: 20,
gap: 18,
},
hero: {
backgroundColor: "transparent",
paddingHorizontal: 22,
paddingTop: 40,
},
heroTopRow: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
},
heroBadge: {
backgroundColor: "#f2b15d",
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
heroBadgeText: {
color: "#13304a",
fontSize: 12,
fontWeight: "700",
letterSpacing: 0.5,
textTransform: "uppercase",
},
settingsCog: {
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 999,
backgroundColor: "#13304a",
marginLeft: 12,
},
panel: {
backgroundColor: "#fffaf1",
borderColor: "#dccfb9",
borderRadius: 24,
borderWidth: 1,
gap: 12,
padding: 18,
alignSelf: "center",
maxWidth: 340,
},
meterValueCentered: {
color: "#d04f2d",
fontSize: 40,
fontWeight: "800",
textAlign: "center",
},
micButton: {
alignItems: "center",
borderRadius: 999,
height: 164,
justifyContent: "center",
marginVertical: 6,
width: 164,
alignSelf: "center",
},
idleButton: {
backgroundColor: "#13304a",
},
holdingButton: {
backgroundColor: "#d04f2d",
transform: [{ scale: 1.08 }],
},
micButtonText: {
color: "#fff6f3",
fontSize: 20,
fontWeight: "800",
},
recordingLabel: {
fontSize: 18,
},
buttonDisabled: {
opacity: 0.45,
},
statusText: {
color: "#1f2d3d",
fontSize: 15,
lineHeight: 21,
textAlign: "center",
},
helperText: {
color: "#665f54",
fontSize: 13,
lineHeight: 18,
textAlign: "center",
},
responseBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
},
responseLabel: {
color: "#13304a",
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,
},
});

252
apk/app/settings.tsx Normal file
View File

@@ -0,0 +1,252 @@
import { Picker } from "@react-native-picker/picker";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
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;
async function loadSettings() {
try {
const settings = await loadRecorderSettings();
if (!isMounted) {
return;
}
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLanguage(settings.language);
setStrings(getStrings(settings.language));
} catch {
if (isMounted) {
Alert.alert(strings.loadError, strings.loadError);
}
}
}
void loadSettings();
return () => {
isMounted = false;
};
}, []);
const langStrings = getStrings(language);
async function handleSave() {
try {
await saveRecorderSettings({
authToken,
backendUrl,
fieldName,
language,
});
setStrings(getStrings(language));
router.back();
} catch {
Alert.alert(langStrings.saveError, langStrings.saveError);
}
}
function handleLanguageChange(val: Locale) {
setLanguage(val);
setStrings(getStrings(val));
}
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.headerRow}>
<Pressable onPress={() => router.back()} style={styles.navButton}>
<Text style={styles.navButtonText}>{langStrings.back}</Text>
</Pressable>
<Text style={styles.title}>{langStrings.settingsTitle}</Text>
<Pressable onPress={handleSave} style={styles.navButton}>
<Text style={styles.navButtonText}>{langStrings.save}</Text>
</Pressable>
</View>
<View style={styles.panel}>
<Text style={styles.label}>{langStrings.backendUrl}</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
onChangeText={setBackendUrl}
placeholder={langStrings.urlPlaceholder}
placeholderTextColor="#8f8a7c"
style={styles.input}
value={backendUrl}
/>
<Text style={styles.label}>{langStrings.bearerToken}</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setAuthToken}
placeholder={langStrings.tokenOptional}
placeholderTextColor="#8f8a7c"
secureTextEntry
style={styles.input}
value={authToken}
/>
<Text style={styles.label}>{langStrings.formFieldName}</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setFieldName}
placeholder={langStrings.fieldNamePlaceholder}
placeholderTextColor="#8f8a7c"
style={styles.input}
value={fieldName}
/>
<Text style={styles.helperText}>
{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>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: "#f4efe4",
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
content: {
gap: 18,
paddingHorizontal: 20,
paddingBottom: 32,
paddingTop: 8,
},
headerRow: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
},
navButton: {
borderColor: "#cdbfa8",
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
},
navButtonText: {
color: "#13304a",
fontSize: 14,
fontWeight: "700",
},
title: {
color: "#13304a",
fontSize: 28,
fontWeight: "800",
},
panel: {
backgroundColor: "#fffaf1",
borderColor: "#dccfb9",
borderRadius: 24,
borderWidth: 1,
gap: 12,
padding: 18,
},
label: {
color: "#13304a",
fontSize: 13,
fontWeight: "700",
marginBottom: -4,
textTransform: "uppercase",
},
input: {
backgroundColor: "#f7f0e0",
borderColor: "#d9ccb5",
borderRadius: 16,
borderWidth: 1,
color: "#1a2b39",
fontSize: 16,
paddingHorizontal: 14,
paddingVertical: 14,
},
helperText: {
color: "#665f54",
fontSize: 13,
lineHeight: 18,
},
pickerWrapper: {
backgroundColor: "#f7f0e0",
borderColor: "#d9ccb5",
borderRadius: 16,
borderWidth: 1,
overflow: "hidden",
},
picker: {
height: 50,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
apk/assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

59
apk/build.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
export ANDROID_HOME=$HOME/android
export ANDROID_SDK_ROOT=$ANDROID_HOME
export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$PATH
set -e # stop on error
echo "🚧 Starting APK build..."
# Step 1: Ensure native android folder exists
if [ ! -d "android" ]; then
echo "📦 Running Expo prebuild..."
npx expo prebuild
fi
# Step 2: Go to android folder
cd android
# Step 3: Check if keystore exists
KEYSTORE_FILE="app/my-release-key.keystore"
if [ ! -f "$KEYSTORE_FILE" ]; then
echo "🔑 Generating keystore..."
keytool -genkeypair -v \
-storetype PKCS12 \
-keystore $KEYSTORE_FILE \
-alias my-key-alias \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass password \
-keypass password \
-dname "CN=Your Name, OU=Dev, O=MyApp, L=City, S=State, C=US"
fi
# Step 4: Ensure gradle.properties has signing config
GRADLE_PROPS="gradle.properties"
if ! grep -q "MYAPP_UPLOAD_STORE_FILE" $GRADLE_PROPS; then
echo "⚙️ Adding signing config..."
cat <<EOF >> $GRADLE_PROPS
MYAPP_UPLOAD_STORE_FILE=my-release-key.keystore
MYAPP_UPLOAD_KEY_ALIAS=my-key-alias
MYAPP_UPLOAD_STORE_PASSWORD=password
MYAPP_UPLOAD_KEY_PASSWORD=password
EOF
fi
# Step 5: Build release APK
echo "🏗️ Building APK..."
./gradlew assembleRelease
# Step 6: Output path
APK_PATH="app/build/outputs/apk/release/app-release.apk"
echo "✅ Build complete!"
echo "📦 APK located at: android/$APK_PATH"

10
apk/eslint.config.js Normal file
View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@@ -0,0 +1,44 @@
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> {
const entries = await AsyncStorage.multiGet([
STORAGE_KEYS.backendUrl,
STORAGE_KEYS.authToken,
STORAGE_KEYS.fieldName,
STORAGE_KEYS.language,
]);
const values = Object.fromEntries(entries);
return {
authToken: values[STORAGE_KEYS.authToken] ?? "",
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
language: (values[STORAGE_KEYS.language] as Locale) ?? "ca",
};
}
export async function saveRecorderSettings(settings: RecorderSettings) {
await AsyncStorage.multiSet([
[STORAGE_KEYS.backendUrl, settings.backendUrl],
[STORAGE_KEYS.authToken, settings.authToken],
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
[STORAGE_KEYS.language, settings.language],
]);
}

View File

@@ -0,0 +1,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;
}

13242
apk/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
apk/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "voice-drop",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-picker/picker": "2.11.1",
"expo": "~54.0.35",
"expo-av": "~16.0.8",
"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-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
},
"private": true
}

17
apk/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

23
backend/.env.example Normal file
View 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

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.log
quibot-audio-*.txt
**/quibot-audio-*.txt

2606
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
backend/package.json Normal file
View 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"
}
}

Binary file not shown.

View 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"
}

View 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
View 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
View 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;

View 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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
View 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
View 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()

View File

@@ -0,0 +1,16 @@
import { Router } from 'express';
import motorController from '../controllers/motor.controller.js';
import audioController from '../controllers/audio.controller.js';
import commandController from '../controllers/command.controller.js';
import settingsController from '../controllers/settings.controller.js';
import 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;

View 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})`);
},
};

View 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 = [];
},
};

View 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();

View 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();

View File

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

View 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();

View 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
View File

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

BIN
igualtat_h3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

1
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.venv/

46
mcp/mcp_server.py Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"formdata-node": "^6.0.3",
"vue-i18n": "^11.4.5"
}
}

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,14 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
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: {
include: [

File diff suppressed because it is too large Load Diff

View File

@@ -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": {
}
}

View File

@@ -0,0 +1,20 @@
export default defineEventHandler(async (event) => {
const direction = getRouterParam(event, 'direction')
if (direction !== 'forward' && direction !== 'backwards') {
throw createError({
statusCode: 400,
statusMessage: 'Invalid motor direction',
})
}
const baseUrl = getPiBaseUrl(event)
const config = useRuntimeConfig()
return await $fetch(`${baseUrl}/motor/step/${direction}`, {
method: 'POST',
query: {
token: config.quibotToken,
},
})
})

View File

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

View File

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

View File

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

81
rasp/server.py Normal file
View 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)