Compare commits

...

12 Commits

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
60 changed files with 12494 additions and 1763 deletions

View File

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

View File

@@ -3,7 +3,7 @@ name: Build
on:
push:
branches:
- master
- main
jobs:
build-web:

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

View File

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

BIN
UPCLogo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -49,6 +49,14 @@
"backgroundColor": "#000000"
}
}
],
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
],
"experiments": {

View File

@@ -1,6 +1,7 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Svg, { Path } from "react-native-svg";
import {
ActivityIndicator,
Alert,
@@ -12,8 +13,8 @@ import {
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { loadRecorderSettings } from "@/lib/recorder-settings";
import { getStrings, type Locale, t } from "@/lib/translations";
function formatDuration(durationMs: number) {
const totalSeconds = Math.floor(durationMs / 1000);
@@ -50,14 +51,20 @@ export default function RecorderScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
const [locale, setLocale] = useState<Locale>("ca");
const [strings, setStrings] = useState(() => getStrings("ca"));
const [recording, setRecording] = useState<Audio.Recording | null>(null);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
const [recordingMs, setRecordingMs] = useState(0);
const [statusMessage, setStatusMessage] = useState(
"Ready to record and send audio.",
);
const [statusMessage, setStatusMessage] = useState("");
const [responsePreview, setResponsePreview] = useState("");
const [llmResponseText, setLlmResponseText] = useState("");
const [transcriptionText, setTranscriptionText] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isHolding, setIsHolding] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const recordingRef = useRef<Audio.Recording | null>(null);
const soundRef = useRef<Audio.Sound | null>(null);
const refreshSettings = useCallback(() => {
let isMounted = true;
@@ -73,9 +80,11 @@ export default function RecorderScreen() {
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLocale(settings.language);
setStrings(getStrings(settings.language));
} catch {
if (isMounted) {
setStatusMessage("Could not load saved backend settings.");
setStatusMessage(strings.loadError);
}
}
}
@@ -115,18 +124,148 @@ export default function RecorderScreen() {
};
}, [recording]);
useEffect(() => {
return () => {
void unloadSound();
};
}, []);
async function unloadSound() {
if (soundRef.current) {
try {
await soundRef.current.stopAsync();
await soundRef.current.unloadAsync();
} catch (err) {
console.log("[TTS] Error unloading sound:", err);
}
soundRef.current = null;
}
setIsPlaying(false);
}
async function speakWithAudio(audioUrl: string, backendBase: string) {
if (!audioUrl) return false;
await unloadSound();
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
shouldDuckAndroid: true,
staysActiveInBackground: false,
});
} catch (err) {
console.log("[TTS] Audio mode error:", err);
}
try {
const fullUrl = audioUrl.startsWith("http")
? audioUrl
: `${backendBase.replace(/\/+$/, "")}/${audioUrl.replace(/^\/+/, "")}`;
console.log("[TTS] Loading audio from:", fullUrl);
setIsPlaying(true);
setStatusMessage(strings.playing);
const { sound } = await Audio.Sound.createAsync(
{ uri: fullUrl },
{ shouldPlay: true, volume: 1.0 },
(status) => {
if (status.isLoaded && status.didJustFinish) {
console.log("[TTS] Audio playback finished");
void unloadSound();
}
},
);
soundRef.current = sound;
const status = await sound.getStatusAsync();
const durationMs = status.isLoaded ? (status.durationMillis ?? 0) : 0;
console.log("[TTS] Playing audio, duration:", durationMs, "ms");
return true;
} catch (err) {
console.log("[TTS] Audio playback error:", err);
setIsPlaying(false);
return false;
}
}
async function speakSequentially(texts: string[]) {
if (texts.length === 0) return;
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
for (let i = 0; i < texts.length; i++) {
const text = texts[i];
if (!text || !text.trim()) continue;
try {
setStatusMessage(strings.playing);
console.log("[TTS] Generating TTS audio for text:", text.substring(0, 50));
const localeLang = locale === "ca" ? "ca" : "en";
const ttsParams = new URLSearchParams({
text: text.trim(),
language: localeLang,
});
if (authToken.trim()) {
ttsParams.append("token", authToken.trim());
}
const ttsUrl = `${trimmedUrl}/tts?${ttsParams.toString()}`;
const ttsResponse = await fetch(ttsUrl, { method: "POST" });
if (!ttsResponse.ok) {
const errText = await ttsResponse.text();
console.log("[TTS] TTS endpoint error:", ttsResponse.status, errText);
continue;
}
const ttsData = await ttsResponse.json();
if (!ttsData.audioUrl) {
console.log("[TTS] No audioUrl in response:", ttsData);
continue;
}
const played = await speakWithAudio(ttsData.audioUrl, trimmedUrl);
if (!played) {
setStatusMessage(strings.uploadFailed);
}
if (i < texts.length - 1) {
await new Promise((r) => setTimeout(r, 800));
}
} catch (err) {
console.log("[TTS] speakSequentially error:", err);
}
}
}
async function speak(text: string) {
const texts = [text].filter(Boolean);
await speakSequentially(texts);
}
async function startRecording() {
try {
await unloadSound();
setTranscriptionText("");
setResponsePreview("");
setLlmResponseText("");
setRecordingUri(null);
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
setStatusMessage("Microphone permission was denied.");
setStatusMessage(strings.micPermissionDenied);
Alert.alert(
"Microphone access required",
"Enable microphone access to record audio.",
strings.micAccessRequiredTitle,
strings.micAccessRequiredMsg,
);
return;
}
@@ -144,26 +283,30 @@ export default function RecorderScreen() {
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
recordingRef.current = result.recording;
setRecording(result.recording);
setRecordingMs(0);
setStatusMessage("Recording in progress.");
setStatusMessage(strings.recording);
} catch (error) {
setStatusMessage("Recording could not be started.");
setStatusMessage(strings.couldNotStartRecording);
Alert.alert(
"Recording failed",
error instanceof Error ? error.message : "Unknown recording error.",
strings.recordingFailedTitle,
error instanceof Error ? error.message : "",
);
}
}
async function stopRecording() {
if (!recording) {
async function stopRecordingAndUpload() {
if (!recordingRef.current) {
return;
}
console.log("[APP] stopRecordingAndUpload called");
try {
const activeRecording = recording;
const activeRecording = recordingRef.current;
const currentStatus = await activeRecording.getStatusAsync();
const durationMillis = currentStatus.durationMillis ?? 0;
await activeRecording.stopAndUnloadAsync();
await Audio.setAudioModeAsync({
@@ -172,25 +315,117 @@ export default function RecorderScreen() {
});
const uri = activeRecording.getURI();
recordingRef.current = null;
setRecording(null);
setRecordingMs(currentStatus.durationMillis ?? recordingMs);
setRecordingUri(uri);
setStatusMessage(
uri ? "Recording finished. Preparing to send voice message." : "Recording finished.",
);
setRecordingMs(durationMillis);
if (uri && backendUrl.trim()) {
await uploadRecording(uri);
if (!uri) {
setStatusMessage(strings.readyToRecord);
return;
}
setRecordingUri(uri);
setStatusMessage(strings.finishedUpload);
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (uploadUrl) {
setIsUploading(true);
try {
const mimeType = buildMimeType(uri);
const extension = buildFileExtension(uri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: uri,
} as never);
const headers: Record<string, string> = {};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(uploadUrl, {
method: "POST",
headers,
body: formData,
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(`${response.status}. ${responseText}`);
}
try {
const data = JSON.parse(responseText);
setResponsePreview(responseText.slice(0, 400));
const textsToSpeak: string[] = [];
if (data.transcription) {
setTranscriptionText(data.transcription);
}
if (data.llmResponse) {
setLlmResponseText(data.llmResponse);
textsToSpeak.push(data.llmResponse);
}
if (textsToSpeak.length > 0) {
setStatusMessage(strings.voiceMessageSent + ". " + strings.playing);
void speakSequentially(textsToSpeak);
} else {
setLlmResponseText("");
}
} catch (parseError) {
console.log("[APP] JSON parse failed:", parseError, "Response was:", responseText.substring(0, 200));
setResponsePreview(responseText.slice(0, 400));
setTranscriptionText("");
setLlmResponseText("");
}
setStatusMessage(strings.voiceMessageSent);
} catch (error) {
setStatusMessage(strings.uploadFailed);
Alert.alert(
strings.uploadFailed,
error instanceof Error ? error.message : "",
);
} finally {
setIsUploading(false);
}
} else {
setStatusMessage(strings.noBackendUrl);
setIsUploading(false);
}
} catch (error) {
setStatusMessage("Recording could not be stopped cleanly.");
recordingRef.current = null;
setRecording(null);
setStatusMessage(strings.stopFailedTitle);
Alert.alert(
"Stop failed",
error instanceof Error ? error.message : "Unknown stop error.",
strings.stopFailedTitle,
error instanceof Error ? error.message : "",
);
}
}
async function handlePressIn() {
if (isUploading) return;
setIsHolding(true);
await startRecording();
}
async function handlePressOut() {
if (!isHolding) return;
setIsHolding(false);
await stopRecordingAndUpload();
}
async function uploadRecording(uriOverride?: string) {
const targetUri = uriOverride ?? recordingUri;
@@ -198,17 +433,23 @@ export default function RecorderScreen() {
return;
}
const trimmedUrl = backendUrl.trim();
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (!trimmedUrl) {
Alert.alert("Missing backend URL", "Enter the backend endpoint first.");
if (!uploadUrl) {
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
return;
}
try {
setIsUploading(true);
setStatusMessage("Uploading recording.");
setStatusMessage(strings.uploadingRecording);
await unloadSound();
setTranscriptionText("");
setResponsePreview("");
setLlmResponseText("");
const mimeType = buildMimeType(targetUri);
const extension = buildFileExtension(targetUri);
@@ -226,33 +467,72 @@ export default function RecorderScreen() {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(trimmedUrl, {
const response = await fetch(uploadUrl, {
method: "POST",
headers,
body: formData,
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
if (!response.ok) {
throw new Error(`Upload failed with ${response.status}. ${responseText}`);
throw new Error(`${response.status}. ${responseText}`);
}
setStatusMessage("Upload complete.");
try {
const data = JSON.parse(responseText);
setResponsePreview(responseText.slice(0, 400));
const textsToSpeak: string[] = [];
if (data.transcription) {
setTranscriptionText(data.transcription);
textsToSpeak.push(data.transcription);
}
if (data.llmResponse) {
setLlmResponseText(data.llmResponse);
textsToSpeak.push(data.llmResponse);
}
if (textsToSpeak.length > 0) {
setStatusMessage(strings.voiceMessageSent + ". " + strings.playing);
void speakSequentially(textsToSpeak);
} else {
setLlmResponseText("");
}
} catch {
setResponsePreview(responseText.slice(0, 400));
setTranscriptionText("");
setLlmResponseText("");
}
setStatusMessage(strings.uploadComplete);
} catch (error) {
setStatusMessage("Upload failed.");
setStatusMessage(strings.uploadFailed);
Alert.alert(
"Upload failed",
error instanceof Error ? error.message : "Unknown upload error.",
strings.uploadFailed,
error instanceof Error ? error.message : "",
);
} finally {
setIsUploading(false);
}
}
function handleSpeak() {
const texts = [transcriptionText, llmResponseText].filter(Boolean);
void speakSequentially(texts);
}
const releaseLabel = t("releaseToStop", locale);
const holdLabel = t("holdToRecord", locale);
const openSettingsLabel = t("openSettingsHint", locale);
const appTitleLabel = t("appTitle", locale);
const recorderTitleLabel = t("recorderTitle", locale);
const serverResponseLabel = t("serverResponse", locale);
return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : undefined}
@@ -265,59 +545,111 @@ export default function RecorderScreen() {
<View style={styles.hero}>
<View style={styles.heroTopRow}>
<View style={styles.heroBadge}>
<Text style={styles.heroBadgeText}>Assistant Voice</Text>
<Text style={styles.heroBadgeText}>{appTitleLabel}</Text>
</View>
<Pressable onPress={() => router.push("/settings")} style={styles.settingsLink}>
<Text style={styles.settingsLinkText}>Settings</Text>
<Pressable
onPress={() => router.push("/settings")}
hitSlop={10}
style={styles.settingsCog}
>
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<Path
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
fill="#d3deea"
/>
<Path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.604.852.997 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08Z"
stroke="#d3deea"
strokeWidth="1.5"
/>
</Svg>
</Pressable>
</View>
<Text style={styles.subtitle}>
Record a voice message and send it to your backend.
</Text>
</View>
<View style={styles.panel}>
<Text style={styles.meterValueCentered}>
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
{formatDuration(recordingMs)}
</Text>
<Pressable
disabled={isUploading}
onPress={recording ? stopRecording : startRecording}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
styles.micButton,
recording ? styles.stopButton : styles.recordButton,
isHolding ? styles.holdingButton : styles.idleButton,
isUploading && styles.buttonDisabled,
]}
>
{isUploading ? (
<ActivityIndicator color="#fff6f3" size="large" />
) : (
<Text style={styles.micButtonText}>
{recording ? "Stop" : "Record"}
</Text>
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
<Path
d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"
fill="#fff6f3"
stroke="#fff6f3"
strokeWidth="1"
/>
<Path
d="M19 10v1a7 7 0 0 1-14 0v-1"
stroke="#fff6f3"
strokeWidth="2"
strokeLinecap="round"
/>
<Path
d="M12 18v3"
stroke="#fff6f3"
strokeWidth="2"
strokeLinecap="round"
/>
</Svg>
)}
</Pressable>
<Text style={styles.statusText}>{statusMessage}</Text>
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
<Text style={styles.helperText}>
{backendUrl.trim()
? recording
? "Tap again when you finish speaking."
: "Tap the button to start a new voice message."
: "Open settings to add your backend URL before sending voice messages."}
{isHolding
? releaseLabel
: backendUrl.trim()
? holdLabel
: openSettingsLabel}
</Text>
{responsePreview ? (
{transcriptionText ? (
<View style={styles.transcriptionBox}>
<Text style={styles.transcriptionLabel}>{strings.yourMessage}</Text>
<Text style={styles.transcriptionText}>{transcriptionText}</Text>
</View>
) : null}
{llmResponseText ? (
<View style={styles.llmResponseBox}>
<View style={styles.llmResponseHeader}>
<Text style={styles.llmResponseLabel}>{strings.aiReply}</Text>
<Pressable onPress={handleSpeak} style={styles.speakButton}>
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<Path d="M11 5L6 9H2v6h4l5 4V5z" fill="#13304a" />
<Path d="M15.5 8.5a5.5 5.5 0 0 1 0 7" stroke="#13304a" strokeWidth="2" strokeLinecap="round" />
<Path d="M18.5 5.5a9 9 0 0 1 0 13" stroke="#13304a" strokeWidth="2" strokeLinecap="round" />
</Svg>
</Pressable>
</View>
<Text style={styles.llmResponseText}>{llmResponseText}</Text>
</View>
) : null}
{responsePreview && !transcriptionText && !llmResponseText ? (
<View style={styles.responseBox}>
<Text style={styles.responseLabel}>Server response</Text>
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
<Text style={styles.responseText}>{responsePreview}</Text>
</View>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</View>
);
}
@@ -333,17 +665,17 @@ const styles = StyleSheet.create({
flex: 1,
},
content: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: 32,
paddingHorizontal: 20,
paddingBottom: 32,
paddingTop: 8,
gap: 18,
},
hero: {
backgroundColor: "#13304a",
borderRadius: 28,
backgroundColor: "transparent",
paddingHorizontal: 22,
paddingVertical: 24,
gap: 12,
paddingTop: 40,
},
heroTopRow: {
alignItems: "center",
@@ -351,7 +683,6 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
},
heroBadge: {
alignSelf: "flex-start",
backgroundColor: "#f2b15d",
borderRadius: 999,
paddingHorizontal: 12,
@@ -364,22 +695,14 @@ const styles = StyleSheet.create({
letterSpacing: 0.5,
textTransform: "uppercase",
},
settingsLink: {
borderColor: "#58718d",
settingsCog: {
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 7,
},
settingsLinkText: {
color: "#d3deea",
fontSize: 13,
fontWeight: "700",
},
subtitle: {
color: "#d3deea",
fontSize: 16,
lineHeight: 22,
backgroundColor: "#13304a",
marginLeft: 12,
},
panel: {
backgroundColor: "#fffaf1",
@@ -388,6 +711,8 @@ const styles = StyleSheet.create({
borderWidth: 1,
gap: 12,
padding: 18,
alignSelf: "center",
maxWidth: 340,
},
meterValueCentered: {
color: "#d04f2d",
@@ -404,17 +729,21 @@ const styles = StyleSheet.create({
width: 164,
alignSelf: "center",
},
recordButton: {
backgroundColor: "#d04f2d",
idleButton: {
backgroundColor: "#13304a",
},
stopButton: {
backgroundColor: "#8c1c13",
holdingButton: {
backgroundColor: "#d04f2d",
transform: [{ scale: 1.08 }],
},
micButtonText: {
color: "#fff6f3",
fontSize: 24,
fontSize: 20,
fontWeight: "800",
},
recordingLabel: {
fontSize: 18,
},
buttonDisabled: {
opacity: 0.45,
},
@@ -422,11 +751,13 @@ const styles = StyleSheet.create({
color: "#1f2d3d",
fontSize: 15,
lineHeight: 21,
textAlign: "center",
},
helperText: {
color: "#665f54",
fontSize: 13,
lineHeight: 18,
textAlign: "center",
},
responseBox: {
backgroundColor: "#f7f0e0",
@@ -440,10 +771,65 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
responseText: {
color: "#36475a",
fontSize: 14,
lineHeight: 20,
},
llmResponseBox: {
backgroundColor: "#e8f4e8",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
borderWidth: 1,
borderColor: "#b8d9b8",
},
llmResponseHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
llmResponseLabel: {
color: "#2a6a2a",
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
llmResponseText: {
color: "#2d4a2d",
fontSize: 16,
lineHeight: 24,
},
speakButton: {
backgroundColor: "#f7f0e0",
borderRadius: 20,
padding: 6,
borderWidth: 1,
borderColor: "#dccfb9",
},
transcriptionBox: {
backgroundColor: "#e8ecf4",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
borderWidth: 1,
borderColor: "#b8c9d9",
},
transcriptionLabel: {
color: "#1a4a6a",
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
transcriptionText: {
color: "#1f3a52",
fontSize: 16,
lineHeight: 24,
},
});

View File

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

View File

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

View File

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

3109
apk/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,20 @@
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.33",
"@react-native-picker/picker": "2.11.1",
"expo": "~54.0.35",
"expo-av": "~16.0.8",
"expo-router": "~6.0.23",
"expo-build-properties": "~1.0.10",
"expo-router": "~6.0.24",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-web": "~0.21.0",
"react-native-screens": "~4.16.0"
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",

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

8
backend/.gitignore vendored
View File

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

View File

@@ -1,210 +0,0 @@
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
import subprocess
import threading
import time
import os
import json
import uuid
import hashlib
from pathlib import Path
from pydantic import BaseModel
import RPi.GPIO as GPIO
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
INCOMING_DIR = Path("/tmp/quibot-audio/incoming")
LOCKED_DIR = Path("/tmp/quibot-audio/locked")
PROCESSED_DIR = Path("/tmp/quibot-audio/processed")
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
LOCKED_DIR.mkdir(parents=True, exist_ok=True)
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
# -------------------------
# GPIO SETUP
# -------------------------
STEP = 23
DIR = 24
EN = 25
GPIO.setmode(GPIO.BCM)
GPIO.setup(STEP, GPIO.OUT)
GPIO.setup(DIR, GPIO.OUT)
GPIO.setup(EN, GPIO.OUT)
GPIO.output(EN, GPIO.LOW)
motor_thread = None
def step_motor(steps, direction, delay=0.001):
GPIO.output(DIR, direction)
for _ in range(steps):
GPIO.output(STEP, GPIO.HIGH)
time.sleep(delay)
GPIO.output(STEP, GPIO.LOW)
time.sleep(delay)
def motor_step(dir):
dir_pin = GPIO.HIGH if dir == "forward" else GPIO.LOW
time.sleep(0.02) # small delay before starting
print("Motor running...")
step_motor(200, dir_pin, 0.001)
# -------------------------
# SAFE COMMAND WHITELIST
# -------------------------
COMMANDS = {
"restart_nginx": ["sudo", "systemctl", "restart", "nginx"],
"uptime": ["uptime"],
"update": ["sudo", "apt", "update"]
}
# -------------------------
# API ENDPOINTS
# -------------------------
@app.post("/run")
def run_task(task: str, token: str):
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
if task not in COMMANDS:
raise HTTPException(status_code=400, detail="Invalid task")
try:
result = subprocess.check_output(COMMANDS[task], text=True)
return {"output": result}
except subprocess.CalledProcessError as e:
return {"error": e.output}
@app.post("/motor/step/forward")
def start_motor(token: str):
global motor_thread
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
motor_thread = threading.Thread(target=motor_step, args=("forward",), daemon=True)
motor_thread.start()
return {"status": "motor started"}
@app.post("/motor/step/backwards")
def start_motor(token: str):
global motor_thread
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
motor_thread = threading.Thread(target=motor_step, args=("backwards",), daemon=True)
motor_thread.start()
return {"status": "motor started"}
@app.post("/motor/stop")
def stop_motor(token: str):
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
GPIO.output(EN, GPIO.HIGH) # disable driver
return {"status": "motor stopped"}
@app.post("/audio/upload")
async def upload_audio(file: UploadFile = File(...), format: str = "wav"):
raw_content = await file.read()
checksum = hashlib.sha256(raw_content).hexdigest()[:16]
filename = f"{checksum[:10]}-{uuid.uuid4().hex[:8]}.wav"
filepath = INCOMING_DIR / filename
filepath.write_bytes(raw_content)
return {"status": "received", "filename": str(filepath), "lock_url": f"/audio/lock/{filepath.name}"}
@app.get("/audio/incoming")
def list_incoming():
files = []
for f in sorted(INCOMING_DIR.iterdir()):
meta = f.stat()
files.append({
"filename": f.name,
"size_bytes": meta.st_size,
"modified_iso": time.ctime(meta.st_mtime),
})
return {"count": len(files), "files": files}
@app.post("/audio/lock/{filename}")
def lock_audio(filename: str):
src = INCOMING_DIR / filename
dst = LOCKED_DIR / filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
if dst.exists():
return {"status": "already_locked", "filename": filename}
os.rename(str(src), str(dst))
return {"status": "locked", "filename": filename}
@app.post("/audio/unlock/{filename}")
def unlock_audio(filename: str):
src = LOCKED_DIR / filename
dst = INCOMING_DIR / filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
os.rename(str(src), str(dst))
return {"status": "unlocked", "filename": filename}
@app.post("/audio/cancel/{filename}")
def cancel_audio(filename: str):
src = LOCKED_DIR / filename
dst = INCOMING_DIR / filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
os.rename(str(src), str(dst))
return {"status": "cancelled", "filename": filename}
@app.post("/audio/process/{filename}")
def process_audio(filename: str):
locked = LOCKED_DIR / filename
processed = PROCESSED_DIR / filename
if not locked.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
os.rename(str(locked), str(processed))
return {"status": "processed", "filename": filename}
@app.on_event("shutdown")
def shutdown():
global motor_running
motor_running = False
GPIO.output(EN, GPIO.HIGH)
GPIO.cleanup()

2606
backend/package-lock.json generated Normal file

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -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

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

View File

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

View File

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

View File

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

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)