Compare commits
1 Commits
main
...
5b9216e764
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b9216e764 |
@@ -2,7 +2,7 @@ name: Build APK
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ master ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -66,6 +66,6 @@ jobs:
|
|||||||
name: Latest Build
|
name: Latest Build
|
||||||
overwrite_files: true
|
overwrite_files: true
|
||||||
files: |
|
files: |
|
||||||
dist/app-release.apk
|
dist/app-release-apk.zip
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA }}
|
GITEA_TOKEN: ${{ secrets.GITEA }}
|
||||||
@@ -3,7 +3,7 @@ name: Build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-web:
|
build-web:
|
||||||
@@ -73,8 +73,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: backend
|
name: backend
|
||||||
path: backend/backend.zip
|
path: backend/backend.zip
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
|||||||
128
AGENTS.md
@@ -2,17 +2,16 @@
|
|||||||
|
|
||||||
## Overview
|
## 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 is an educational robotics platform (UPC/UNE collaboration) consisting of a programmable robot with color-block recognition, gesture control, stepper motors, RGB LED eyes, and multiple input methods (web dashboard, Android voice app). The codebase comprises four independent application layers communicating via HTTP JSON APIs.
|
||||||
|
|
||||||
```
|
```
|
||||||
[quibot-web Nuxt SPA] ──HTTP──> [backend Express]
|
[quibot-web Nuxt SPA] ──HTTP──> [backend Express] ──HTTP──> [raspi FastAPI (Pi)]
|
||||||
│ │
|
│ │
|
||||||
▼ ├──▶ Raspberry Pi (port 8000) — motor/audio endpoints
|
▼ ▼
|
||||||
[apk Expo RN app] ──HTTP──> ├──▶ LLM (llamacpp)
|
[apk Expo RN app] ──HTTP──> (same backend) [Python hardware drivers]
|
||||||
└──▶ TTS (Piper)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Tech stack**: TypeScript/Express | Nuxt 4/Vue 3 | Expo/React Native
|
**Tech stack**: Python (pigpio, FastAPI) | TypeScript/Express | Nuxt 4/Vue 3 | Expo/React Native
|
||||||
**No database**: All state is in-memory, file-based (`/tmp/quibot-audio/`), or localStorage.
|
**No database**: All state is in-memory, file-based (`/tmp/quibot-audio/`), or localStorage.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -21,22 +20,30 @@ QuiBot is an educational robotics platform (UPC/UNE collaboration) consisting of
|
|||||||
|
|
||||||
```
|
```
|
||||||
quibot/
|
quibot/
|
||||||
├── backend/ # Express server (port 5000), proxy to Pi + LLM/TTS
|
├── raspi/ # Raspberry Pi brain — Python, controls hardware
|
||||||
|
│ ├── main.py # FastAPI server (port 8000) on the Pi
|
||||||
|
│ ├── quibot.py # Main program: block/gesture threads (like Arduino QuiBot.ino)
|
||||||
|
│ ├── motion.py # Stepper class, homing, line-following, high-level tasks
|
||||||
|
│ ├── gesture.py # PAJ7620U2 gesture sensor (I2C, polled at 50ms)
|
||||||
|
│ ├── blocks.py # TCS34725 color sensor + servo block ejection
|
||||||
|
│ ├── eyes.py # WS2811 LED matrix (128 LEDs, pigpio waveforms, breathing animation)
|
||||||
|
│ ├── pins.py # BCM GPIO pin map for all hardware
|
||||||
|
│ └── tests/ # Manual diagnostic scripts (not automated)
|
||||||
|
├── backend/ # Local Express server (port 3000), proxy to Pi
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── index.ts # Express entry: CORS, JSON parser, /health, routes
|
│ │ ├── index.ts # Express entry: CORS, JSON parser, /health
|
||||||
│ │ ├── config.ts # Env config: raspi, PIPER_URL, LLAMA_CPP_URL, PORT
|
│ │ ├── config.ts # Env config: RASPBERRY_PI_HOST, PORT, QUIBOT_TOKEN
|
||||||
│ │ ├── routes/router.ts # Mounts all controllers
|
│ │ ├── routes/router.ts # Mounts all controllers
|
||||||
│ │ ├── services/raspi.service.ts # Axios proxy layer to Pi endpoints
|
│ │ ├── services/raspi.service.ts # Axios proxy layer to Pi FastAPI
|
||||||
│ │ └── controllers/
|
│ │ └── controllers/
|
||||||
│ │ ├── motor.controller.ts # Motor step/stop/upload
|
│ │ ├── motor.controller.ts # Motor step/stop/upload
|
||||||
│ │ ├── audio.controller.ts # Audio file lifecycle (incoming/locked/processed)
|
│ │ ├── audio.controller.ts # Audio file lifecycle (incoming/locked/processed)
|
||||||
│ │ ├── command.controller.ts # POST /commands proxy to raspi /run
|
│ │ ├── command.controller.ts # POST /commands proxy to raspi /run
|
||||||
│ │ ├── settings.controller.ts # GET/PUT /settings runtime config
|
│ │ └── settings.controller.ts # GET/PUT /settings runtime config
|
||||||
│ │ └── tts.controller.ts # TTS synthesis via Piper
|
|
||||||
│ └── dist/ # Compiled output (generated)
|
│ └── dist/ # Compiled output (generated)
|
||||||
├── quibot-web/ # Nuxt 4 dashboard SPA
|
├── quibot-web/ # Nuxt 4 dashboard SPA
|
||||||
│ ├── app/app.vue # Single-page control panel: block queue, D-pad, eye controls, gesture log
|
│ ├── app/app.vue # Single-page control panel: block queue, D-pad, eye controls, gesture log
|
||||||
│ ├── server/api/ # Nitro server routes proxying to backend Express
|
│ ├── server/api/ # Nitro server routes proxying to raspi
|
||||||
│ │ ├── motor/step/[direction].post.ts
|
│ │ ├── motor/step/[direction].post.ts
|
||||||
│ │ └── motor/stop.post.ts
|
│ │ └── motor/stop.post.ts
|
||||||
│ ├── nuxt.config.ts # Runtime config: QUIBOT_BASE_URL, QUIBOT_TOKEN
|
│ ├── nuxt.config.ts # Runtime config: QUIBOT_BASE_URL, QUIBOT_TOKEN
|
||||||
@@ -54,9 +61,7 @@ quibot/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Raspberry Pi Layer (remote, port 8000)
|
## Raspberry Pi Layer (`raspi/`)
|
||||||
|
|
||||||
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:
|
**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
|
- 5 NEMA-style stepper motors (wheels x2, arms x2, syringe) via A4988/TB600 drivers in STEP/DIR mode
|
||||||
@@ -69,7 +74,7 @@ The Raspberry Pi runs a lightweight HTTP server exposing hardware control endpoi
|
|||||||
- Hall-effect endstops on GPIOs 12, 16, 17
|
- Hall-effect endstops on GPIOs 12, 16, 17
|
||||||
- Optional: I2S audio amp (MAX98357A) + mic (SPH0645)
|
- Optional: I2S audio amp (MAX98357A) + mic (SPH0645)
|
||||||
|
|
||||||
### Hardware source files (on Pi)
|
### Key files
|
||||||
|
|
||||||
- **`pins.py`** — BCM GPIO pin numbering for every component (STEP, DIR, EN pins, I2C lines, endstops, LED_DATA on GPIO26)
|
- **`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.
|
- **`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.
|
||||||
@@ -99,7 +104,7 @@ The Raspberry Pi runs a lightweight HTTP server exposing hardware control endpoi
|
|||||||
|
|
||||||
## Backend Layer (`backend/`)
|
## Backend Layer (`backend/`)
|
||||||
|
|
||||||
**Role**: Express.js HTTP server providing frontend/mobile API, proxying hardware commands to the Raspberry Pi, and managing TTS/LLM integration.
|
**Role**: Express.js HTTP proxy sitting between frontend/mobile and the Raspberry Pi's FastAPI server. Token passthrough, no business logic.
|
||||||
|
|
||||||
### Configuration (`.env`, loaded by `config.ts`)
|
### Configuration (`.env`, loaded by `config.ts`)
|
||||||
|
|
||||||
@@ -108,19 +113,15 @@ The Raspberry Pi runs a lightweight HTTP server exposing hardware control endpoi
|
|||||||
| `RASPBERRY_PI_HOST` | `http://raspberrypi.local` | Pi API URL |
|
| `RASPBERRY_PI_HOST` | `http://raspberrypi.local` | Pi API URL |
|
||||||
| `RASPBERRY_PI_PORT` | `8000` | Pi API port |
|
| `RASPBERRY_PI_PORT` | `8000` | Pi API port |
|
||||||
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token for all Pi endpoints |
|
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token for all Pi endpoints |
|
||||||
| `PORT` | `5000` | Backend listen port |
|
| `PORT` | `3000` | 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
|
### Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
index.ts → Express app, CORS, JSON parser, /health endpoint
|
index.ts → Express app, CORS, JSON parser, /health endpoint
|
||||||
routes/router.ts → Mounts all controllers under /motor, /audio, /commands, /settings, /tts
|
routes/router.ts → Mounts all controllers under /motor, /audio, /commands, /settings
|
||||||
config.ts → Mutable getter/setter env vars (runtime update via PUT /settings)
|
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
|
raspi.service.ts → Axios proxy methods for every Pi endpoint + multipart file upload handling
|
||||||
```
|
```
|
||||||
|
|
||||||
### Controllers
|
### Controllers
|
||||||
@@ -129,7 +130,6 @@ raspi.service.ts → Axios proxy methods for Pi endpoints + multipart file uplo
|
|||||||
- **`audio.controller.ts`** — `GET /audio/incoming`, `POST /audio/lock/:filename`, `/unlock/:filename`, `/cancel/:filename`, `/process/:filename`. All proxy to raspi audio file lifecycle endpoints.
|
- **`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=...`
|
- **`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.
|
- **`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
|
### Build/Run
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ node dist/index.js # Or use tsx/nodemon for dev
|
|||||||
|
|
||||||
| Key | Default | Purpose |
|
| Key | Default | Purpose |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `QUIBOT_BASE_URL` | `http://quibot:8000` | Base URL for backend Express (or Pi) |
|
| `QUIBOT_BASE_URL` | `http://quibot:8000` | Base URL for raspi FastAPI |
|
||||||
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token |
|
| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token |
|
||||||
|
|
||||||
### UI Panels (`app/app.vue` — single-file SPA, 1369 lines)
|
### UI Panels (`app/app.vue` — single-file SPA, 1369 lines)
|
||||||
@@ -170,8 +170,8 @@ node dist/index.js # Or use tsx/nodemon for dev
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| POST | `/api/motor/stop` | Proxies to backend Express `/motor/stop` |
|
| POST | `/api/motor/stop` | Proxies to raspi `/motor/stop` |
|
||||||
| POST | `/api/motor/step/:direction` | Proxies to backend Express `/motor/step/forward\|backwards` |
|
| POST | `/api/motor/step/:direction` | Proxies to raspi `/motor/step/forward\|backwards` |
|
||||||
|
|
||||||
**Note**: The frontend also calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`, `/api/gesture/on`, `/api/gesture/off` — server routes for these may need to be created (frontend references them but they don't have explicit server handlers yet).
|
**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).
|
||||||
|
|
||||||
@@ -221,26 +221,7 @@ CI: `build-apk.yml` runs expo prebuild, decodes keystore from secrets, builds si
|
|||||||
|
|
||||||
## Complete API Reference
|
## Complete API Reference
|
||||||
|
|
||||||
### Backend Express (`backend/`) — port 5000
|
### Raspberry Pi FastAPI (`raspi/main.py`) — port 8000
|
||||||
|
|
||||||
| 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 |
|
| Method | Path | Params/Body | Description |
|
||||||
|--------|------|-------------|-------------|
|
|--------|------|-------------|-------------|
|
||||||
@@ -257,6 +238,24 @@ CI: `build-apk.yml` runs expo prebuild, decodes keystore from secrets, builds si
|
|||||||
|
|
||||||
**Auth**: Query parameter `token` matching `QUIBOT_TOKEN` env var (default: `MY_SECRET_TOKEN`).
|
**Auth**: Query parameter `token` matching `QUIBOT_TOKEN` env var (default: `MY_SECRET_TOKEN`).
|
||||||
|
|
||||||
|
### Backend Express (`backend/`) — port 3000
|
||||||
|
|
||||||
|
| Method | Path | Body | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| GET | `/health` | — | Returns settings object |
|
||||||
|
| POST | `/commands` | `{ task }` | Proxy to raspi `/run` |
|
||||||
|
| POST | `/motor/step/forward` | — | Motor forward proxy |
|
||||||
|
| POST | `/motor/step/backward` | — | Motor backward proxy (maps to raspi `/backwards`) |
|
||||||
|
| POST | `/motor/stop` | — | Motor stop proxy |
|
||||||
|
| POST | `/motor/upload` | multipart file | Audio upload via multer in-memory buffer |
|
||||||
|
| GET | `/audio/incoming` | — | List incoming audio files |
|
||||||
|
| POST | `/audio/lock/:filename` | — | Lock audio file |
|
||||||
|
| POST | `/audio/unlock/:filename` | — | Unlock audio file |
|
||||||
|
| POST | `/audio/cancel/:filename` | — | Cancel locked audio |
|
||||||
|
| POST | `/audio/process/:filename` | — | Mark processed |
|
||||||
|
| GET | `/settings` | — | Returns config |
|
||||||
|
| PUT | `/settings` | `{ raspberryPi: { host, port }, token }` | Update runtime config |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Command Flow Examples
|
## Command Flow Examples
|
||||||
@@ -267,11 +266,9 @@ User clicks "Forward" in D-pad
|
|||||||
→ $fetch('/api/motor/step/forward', { method: 'POST' })
|
→ $fetch('/api/motor/step/forward', { method: 'POST' })
|
||||||
→ Nuxt Nitro route: server/api/motor/step/[direction].post.ts
|
→ Nuxt Nitro route: server/api/motor/step/[direction].post.ts
|
||||||
→ $fetch(config.quibotBaseUrl + '/motor/step/forward', { query: { token } })
|
→ $fetch(config.quibotBaseUrl + '/motor/step/forward', { query: { token } })
|
||||||
→ Backend Express /motor/step/forward
|
→ raspi FastAPI /motor/step/forward
|
||||||
→ raspi.service.motorStepForward()
|
→ motor_step("forward") in daemon thread
|
||||||
→ Pi FastAPI /motor/step/forward?token=...
|
→ step_motor(200, DIR, 1ms pulses)
|
||||||
→ motor_step("forward") in daemon thread on Pi
|
|
||||||
→ step_motor(200, DIR, 1ms pulses)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Block Processing (internal to Pi)
|
### Block Processing (internal to Pi)
|
||||||
@@ -281,18 +278,8 @@ Child inserts colored block → quibot.py task_read_blocks() polls distance sens
|
|||||||
→ Manhattan distance classification against color lookup table
|
→ Manhattan distance classification against color lookup table
|
||||||
→ RED: eyes_turn_on(EYES_FW, DARK_RED, 2)
|
→ RED: eyes_turn_on(EYES_FW, DARK_RED, 2)
|
||||||
_execute_action(task_move_to, CROSSING)
|
_execute_action(task_move_to, CROSSING)
|
||||||
→ enable_wheels(ON) → follow_line_loop(speed) (proportional on TCRT5000)
|
→ enable_wheels(ON) → follow_line_loop(speed) (proportional on TCRT5000)
|
||||||
→ After action: servo_move_to(EJECT_POSITION)
|
→ 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
|
### APK → Audio Upload
|
||||||
@@ -330,9 +317,8 @@ User toggles mode in web UI
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests reside on the Pi alongside the hardware source code, not in this repository.
|
All tests in `raspi/tests/` are **manual diagnostic scripts** (not automated frameworks). Each test is run independently by uncommenting the desired function call at the bottom of the file. Requirements:
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- `sudo pigpiod -s 1` daemon running
|
- `sudo pigpiod -s 1` daemon running
|
||||||
- Python venv activated with hardware dependencies installed
|
- Python venv activated with hardware dependencies installed
|
||||||
- Pi connected to robot hardware
|
- Pi connected to robot hardware
|
||||||
@@ -351,8 +337,8 @@ Requirements:
|
|||||||
|
|
||||||
1. **Token auth** is always a query parameter matching `QUIBOT_TOKEN` (default: `MY_SECRET_TOKEN`)
|
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
|
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
|
3. **Backend is a dumb proxy** — no business logic, just forwards HTTP requests with token passthrough
|
||||||
4. **Motor commands are fire-and-forget** — motor runs in daemon thread on Pi until `/motor/stop`
|
4. **Motor commands are fire-and-forget** — motor runs in daemon thread until `/motor/stop`
|
||||||
5. **Audio lifecycle**: incoming → locked (claim) → processed OR unlocked (release) / cancelled
|
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
|
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
|
7. **`quibot.py` owns block/gesture autonomy** — blocks are processed internally on the Pi without backend/web involvement
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
"@react-native-voice/voice",
|
||||||
[
|
[
|
||||||
"expo-av",
|
"expo-av",
|
||||||
{
|
{
|
||||||
@@ -49,14 +50,6 @@
|
|||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-build-properties",
|
|
||||||
{
|
|
||||||
"android": {
|
|
||||||
"usesCleartextTraffic": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
|
import Voice from "@react-native-voice/voice";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import { router, useFocusEffect } from "expo-router";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Svg, { Path } from "react-native-svg";
|
import Svg, { Path } from "react-native-svg";
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
|
NativeModules,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -26,45 +27,22 @@ function formatDuration(durationMs: number) {
|
|||||||
.padStart(2, "0")}`;
|
.padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMimeType(uri: string) {
|
|
||||||
const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase();
|
|
||||||
|
|
||||||
switch (extension) {
|
|
||||||
case "wav":
|
|
||||||
return "audio/wav";
|
|
||||||
case "caf":
|
|
||||||
return "audio/x-caf";
|
|
||||||
case "webm":
|
|
||||||
return "audio/webm";
|
|
||||||
case "mp3":
|
|
||||||
return "audio/mpeg";
|
|
||||||
default:
|
|
||||||
return "audio/m4a";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFileExtension(uri: string) {
|
|
||||||
return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RecorderScreen() {
|
export default function RecorderScreen() {
|
||||||
const [backendUrl, setBackendUrl] = useState("");
|
const [backendUrl, setBackendUrl] = useState("");
|
||||||
const [authToken, setAuthToken] = useState("");
|
const [authToken, setAuthToken] = useState("");
|
||||||
const [fieldName, setFieldName] = useState("file");
|
|
||||||
const [locale, setLocale] = useState<Locale>("ca");
|
const [locale, setLocale] = useState<Locale>("ca");
|
||||||
const [strings, setStrings] = useState(() => getStrings("ca"));
|
const [strings, setStrings] = useState(() => getStrings("ca"));
|
||||||
const [recording, setRecording] = useState<Audio.Recording | null>(null);
|
|
||||||
const [recordingUri, setRecordingUri] = useState<string | null>(null);
|
const [transcript, setTranscript] = useState("");
|
||||||
const [recordingMs, setRecordingMs] = useState(0);
|
const [interimTranscript, setInterimTranscript] = useState("");
|
||||||
const [statusMessage, setStatusMessage] = useState("");
|
const [statusMessage, setStatusMessage] = useState("");
|
||||||
const [responsePreview, setResponsePreview] = useState("");
|
const [responsePreview, setResponsePreview] = useState("");
|
||||||
const [llmResponseText, setLlmResponseText] = useState("");
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [transcriptionText, setTranscriptionText] = useState("");
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [isHolding, setIsHolding] = useState(false);
|
const [isHolding, setIsHolding] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [listeningMs, setListeningMs] = useState(0);
|
||||||
const recordingRef = useRef<Audio.Recording | null>(null);
|
const startRef = useRef<number>(0);
|
||||||
const soundRef = useRef<Audio.Sound | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const listeningActiveRef = useRef(false);
|
||||||
|
|
||||||
const refreshSettings = useCallback(() => {
|
const refreshSettings = useCallback(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -79,7 +57,6 @@ export default function RecorderScreen() {
|
|||||||
|
|
||||||
setBackendUrl(settings.backendUrl);
|
setBackendUrl(settings.backendUrl);
|
||||||
setAuthToken(settings.authToken);
|
setAuthToken(settings.authToken);
|
||||||
setFieldName(settings.fieldName);
|
|
||||||
setLocale(settings.language);
|
setLocale(settings.language);
|
||||||
setStrings(getStrings(settings.language));
|
setStrings(getStrings(settings.language));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,415 +76,187 @@ export default function RecorderScreen() {
|
|||||||
useFocusEffect(refreshSettings);
|
useFocusEffect(refreshSettings);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recording) {
|
if (Platform.OS === "web" || !NativeModules.Voice) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
Voice.onSpeechStart = (e) => {
|
||||||
void recording.getStatusAsync().then((status) => {
|
console.log("Voice.onSpeechStart", e);
|
||||||
if (typeof status.durationMillis === "number") {
|
setTranscript("");
|
||||||
setRecordingMs(status.durationMillis ?? 0);
|
setInterimTranscript("");
|
||||||
}
|
setStatusMessage(strings.recording);
|
||||||
});
|
startRef.current = Date.now();
|
||||||
}, 250);
|
timerRef.current = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startRef.current;
|
||||||
return () => {
|
setListeningMs(elapsed);
|
||||||
clearInterval(interval);
|
}, 250);
|
||||||
};
|
};
|
||||||
}, [recording]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
Voice.onSpeechEnd = (e) => {
|
||||||
return () => {
|
console.log("Voice.onSpeechEnd", e);
|
||||||
if (recording) {
|
listeningActiveRef.current = false;
|
||||||
void recording.stopAndUnloadAsync().catch(() => undefined);
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [recording]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
Voice.onSpeechResults = (e) => {
|
||||||
|
console.log("Voice.onSpeechResults", e);
|
||||||
|
const values = (e as unknown as { value: string[] }).value;
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
setTranscript(last);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Voice.onSpeechPartialResults = (e) => {
|
||||||
|
console.log("Voice.onSpeechPartialResults", e);
|
||||||
|
const values = (e as unknown as { value: string[] }).value;
|
||||||
|
if (values && values.length > 0) {
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
setInterimTranscript(last || "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Voice.onSpeechError = (e) => {
|
||||||
|
console.log("Voice.onSpeechError", e);
|
||||||
|
const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error";
|
||||||
|
setStatusMessage(message);
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setListeningMs(0);
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
Alert.alert(strings.recordingFailedTitle, message);
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
void unloadSound();
|
Voice.removeAllListeners();
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function unloadSound() {
|
useEffect(() => {
|
||||||
if (soundRef.current) {
|
return () => {
|
||||||
try {
|
if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) {
|
||||||
await soundRef.current.stopAsync();
|
Voice.stop().catch(() => undefined);
|
||||||
await soundRef.current.unloadAsync();
|
listeningActiveRef.current = false;
|
||||||
} 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();
|
|
||||||
|
|
||||||
|
async function startListening() {
|
||||||
try {
|
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("");
|
setResponsePreview("");
|
||||||
setLlmResponseText("");
|
setTranscript("");
|
||||||
setRecordingUri(null);
|
setInterimTranscript("");
|
||||||
|
const localeCode =
|
||||||
const permission = await Audio.requestPermissionsAsync();
|
locale.includes("ca")
|
||||||
|
? "ca-ES"
|
||||||
if (!permission.granted) {
|
: locale.includes("es")
|
||||||
setStatusMessage(strings.micPermissionDenied);
|
? "es-ES"
|
||||||
Alert.alert(
|
: "en-US";
|
||||||
strings.micAccessRequiredTitle,
|
setIsHolding(true);
|
||||||
strings.micAccessRequiredMsg,
|
if (Platform.OS === "web") {
|
||||||
);
|
console.log("Voice not available on web");
|
||||||
|
Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS.");
|
||||||
|
setIsHolding(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!NativeModules.Voice) {
|
||||||
await Audio.setAudioModeAsync({
|
Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules.");
|
||||||
allowsRecordingIOS: true,
|
setIsHolding(false);
|
||||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
|
return;
|
||||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
}
|
||||||
playsInSilentModeIOS: true,
|
await Voice.start(localeCode);
|
||||||
shouldDuckAndroid: true,
|
listeningActiveRef.current = true;
|
||||||
staysActiveInBackground: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await Audio.Recording.createAsync(
|
|
||||||
Audio.RecordingOptionsPresets.HIGH_QUALITY,
|
|
||||||
);
|
|
||||||
|
|
||||||
recordingRef.current = result.recording;
|
|
||||||
setRecording(result.recording);
|
|
||||||
setRecordingMs(0);
|
|
||||||
setStatusMessage(strings.recording);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(strings.couldNotStartRecording);
|
const msg = error instanceof Error ? error.message : "Failed to start speech recognition";
|
||||||
Alert.alert(
|
console.error("Voice.start failed:", error);
|
||||||
strings.recordingFailedTitle,
|
Alert.alert(strings.recordingFailedTitle, msg);
|
||||||
error instanceof Error ? error.message : "",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecordingAndUpload() {
|
async function stopListeningAndSend() {
|
||||||
if (!recordingRef.current) {
|
setIsHolding(false);
|
||||||
|
const wasListening = listeningActiveRef.current;
|
||||||
|
if (!wasListening) {
|
||||||
|
setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listeningActiveRef.current = false;
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
|
||||||
|
if (finalText) {
|
||||||
|
await sendCommand(finalText);
|
||||||
|
} else {
|
||||||
|
setStatusMessage(strings.readyToRecord);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[APP] stopRecordingAndUpload called");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activeRecording = recordingRef.current;
|
await Voice.stop();
|
||||||
const currentStatus = await activeRecording.getStatusAsync();
|
|
||||||
const durationMillis = currentStatus.durationMillis ?? 0;
|
|
||||||
|
|
||||||
await activeRecording.stopAndUnloadAsync();
|
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
|
||||||
await Audio.setAudioModeAsync({
|
|
||||||
allowsRecordingIOS: false,
|
|
||||||
playsInSilentModeIOS: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uri = activeRecording.getURI();
|
if (!finalText) {
|
||||||
recordingRef.current = null;
|
|
||||||
setRecording(null);
|
|
||||||
setRecordingMs(durationMillis);
|
|
||||||
|
|
||||||
if (!uri) {
|
|
||||||
setStatusMessage(strings.readyToRecord);
|
setStatusMessage(strings.readyToRecord);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRecordingUri(uri);
|
await sendCommand(finalText);
|
||||||
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) {
|
} catch (error) {
|
||||||
recordingRef.current = null;
|
listeningActiveRef.current = false;
|
||||||
setRecording(null);
|
const msg = error instanceof Error ? error.message : "Stop failed";
|
||||||
setStatusMessage(strings.stopFailedTitle);
|
console.error("Voice.stop failed:", error);
|
||||||
Alert.alert(
|
setStatusMessage(msg);
|
||||||
strings.stopFailedTitle,
|
|
||||||
error instanceof Error ? error.message : "",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePressIn() {
|
async function sendCommand(text: string) {
|
||||||
if (isUploading) return;
|
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
|
||||||
setIsHolding(true);
|
const commandUrl = trimmedUrl.endsWith("/commands")
|
||||||
await startRecording();
|
? `${trimmedUrl}/text`
|
||||||
}
|
: `${trimmedUrl}/commands/text`;
|
||||||
|
|
||||||
async function handlePressOut() {
|
if (!commandUrl) {
|
||||||
if (!isHolding) return;
|
setStatusMessage(strings.noBackendUrl);
|
||||||
setIsHolding(false);
|
|
||||||
await stopRecordingAndUpload();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadRecording(uriOverride?: string) {
|
|
||||||
const targetUri = uriOverride ?? recordingUri;
|
|
||||||
|
|
||||||
if (!targetUri) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
|
|
||||||
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
|
|
||||||
? trimmedUrl
|
|
||||||
: `${trimmedUrl}/audio/upload`;
|
|
||||||
|
|
||||||
if (!uploadUrl) {
|
|
||||||
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsSending(true);
|
||||||
setStatusMessage(strings.uploadingRecording);
|
setStatusMessage(strings.uploadingRecording);
|
||||||
await unloadSound();
|
|
||||||
setTranscriptionText("");
|
|
||||||
setResponsePreview("");
|
|
||||||
setLlmResponseText("");
|
|
||||||
|
|
||||||
const mimeType = buildMimeType(targetUri);
|
const headers: Record<string, string> = {
|
||||||
const extension = buildFileExtension(targetUri);
|
"Content-Type": "application/json",
|
||||||
const formData = new FormData();
|
};
|
||||||
|
|
||||||
formData.append(fieldName.trim() || "file", {
|
|
||||||
name: `recording-${Date.now()}.${extension}`,
|
|
||||||
type: mimeType,
|
|
||||||
uri: targetUri,
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (authToken.trim()) {
|
if (authToken.trim()) {
|
||||||
headers.Authorization = `Bearer ${authToken.trim()}`;
|
headers.Authorization = `Bearer ${authToken.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(uploadUrl, {
|
const response = await fetch(commandUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: formData,
|
body: JSON.stringify({ text }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
|
setResponsePreview(responseText.slice(0, 400));
|
||||||
|
setTranscript("");
|
||||||
|
setInterimTranscript("");
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`${response.status}. ${responseText}`);
|
throw new Error(`${response.status}. ${responseText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
setStatusMessage(strings.voiceMessageSent);
|
||||||
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) {
|
} catch (error) {
|
||||||
setStatusMessage(strings.uploadFailed);
|
setStatusMessage(strings.uploadFailed);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -515,22 +264,28 @@ export default function RecorderScreen() {
|
|||||||
error instanceof Error ? error.message : "",
|
error instanceof Error ? error.message : "",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSpeak() {
|
async function handlePressIn() {
|
||||||
const texts = [transcriptionText, llmResponseText].filter(Boolean);
|
if (isSending) return;
|
||||||
void speakSequentially(texts);
|
await startListening();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePressOut() {
|
||||||
|
await stopListeningAndSend();
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseLabel = t("releaseToStop", locale);
|
const releaseLabel = t("releaseToStop", locale);
|
||||||
const holdLabel = t("holdToRecord", locale);
|
const holdLabel = t("holdToRecord", locale);
|
||||||
const openSettingsLabel = t("openSettingsHint", locale);
|
const openSettingsHint = t("openSettingsHint", locale);
|
||||||
const appTitleLabel = t("appTitle", locale);
|
const appTitleLabel = t("appTitle", locale);
|
||||||
const recorderTitleLabel = t("recorderTitle", locale);
|
const recorderTitleLabel = t("recorderTitle", locale);
|
||||||
const serverResponseLabel = t("serverResponse", locale);
|
const serverResponseLabel = t("serverResponse", locale);
|
||||||
|
|
||||||
|
const displayText = interimTranscript || transcript;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -569,20 +324,20 @@ export default function RecorderScreen() {
|
|||||||
|
|
||||||
<View style={styles.panel}>
|
<View style={styles.panel}>
|
||||||
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
|
||||||
{formatDuration(recordingMs)}
|
{formatDuration(listeningMs)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={isUploading}
|
disabled={isSending}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
style={[
|
style={[
|
||||||
styles.micButton,
|
styles.micButton,
|
||||||
isHolding ? styles.holdingButton : styles.idleButton,
|
isHolding ? styles.holdingButton : styles.idleButton,
|
||||||
isUploading && styles.buttonDisabled,
|
isSending && styles.buttonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isSending ? (
|
||||||
<ActivityIndicator color="#fff6f3" size="large" />
|
<ActivityIndicator color="#fff6f3" size="large" />
|
||||||
) : (
|
) : (
|
||||||
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -608,44 +363,23 @@ export default function RecorderScreen() {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
|
<Text style={styles.statusText}>
|
||||||
|
{statusMessage || strings.readyToRecord}
|
||||||
|
</Text>
|
||||||
<Text style={styles.helperText}>
|
<Text style={styles.helperText}>
|
||||||
{isHolding
|
{isHolding
|
||||||
? releaseLabel
|
? releaseLabel
|
||||||
: backendUrl.trim()
|
: backendUrl.trim()
|
||||||
? holdLabel
|
? holdLabel
|
||||||
: openSettingsLabel}
|
: openSettingsHint}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{transcriptionText ? (
|
{displayText && (
|
||||||
<View style={styles.transcriptionBox}>
|
<View style={styles.transcriptBox}>
|
||||||
<Text style={styles.transcriptionLabel}>{strings.yourMessage}</Text>
|
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
|
||||||
<Text style={styles.transcriptionText}>{transcriptionText}</Text>
|
<Text style={styles.transcriptText}>{displayText}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{llmResponseText ? (
|
|
||||||
<View style={styles.llmResponseBox}>
|
|
||||||
<View style={styles.llmResponseHeader}>
|
|
||||||
<Text style={styles.llmResponseLabel}>{strings.aiReply}</Text>
|
|
||||||
<Pressable onPress={handleSpeak} style={styles.speakButton}>
|
|
||||||
<Svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
||||||
<Path d="M11 5L6 9H2v6h4l5 4V5z" fill="#13304a" />
|
|
||||||
<Path d="M15.5 8.5a5.5 5.5 0 0 1 0 7" stroke="#13304a" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<Path d="M18.5 5.5a9 9 0 0 1 0 13" stroke="#13304a" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</Svg>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.llmResponseText}>{llmResponseText}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{responsePreview && !transcriptionText && !llmResponseText ? (
|
|
||||||
<View style={styles.responseBox}>
|
|
||||||
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
|
|
||||||
<Text style={styles.responseText}>{responsePreview}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -736,14 +470,6 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: "#d04f2d",
|
backgroundColor: "#d04f2d",
|
||||||
transform: [{ scale: 1.08 }],
|
transform: [{ scale: 1.08 }],
|
||||||
},
|
},
|
||||||
micButtonText: {
|
|
||||||
color: "#fff6f3",
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "800",
|
|
||||||
},
|
|
||||||
recordingLabel: {
|
|
||||||
fontSize: 18,
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.45,
|
opacity: 0.45,
|
||||||
},
|
},
|
||||||
@@ -759,6 +485,28 @@ const styles = StyleSheet.create({
|
|||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
transcriptBox: {
|
||||||
|
backgroundColor: "#f7f0e0",
|
||||||
|
borderRadius: 16,
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
padding: 14,
|
||||||
|
maxWidth: "100%",
|
||||||
|
},
|
||||||
|
transcriptLabel: {
|
||||||
|
color: "#13304a",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
transcriptText: {
|
||||||
|
color: "#36475a",
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 22,
|
||||||
|
textAlign: "center",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
responseBox: {
|
responseBox: {
|
||||||
backgroundColor: "#f7f0e0",
|
backgroundColor: "#f7f0e0",
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
@@ -778,58 +526,4 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 20,
|
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ export function en() {
|
|||||||
releaseToStop: "Release your finger to stop recording and send the audio.",
|
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.",
|
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.",
|
openSettingsHint: "Open settings to add your backend URL before sending voice messages.",
|
||||||
aiReply: "Quibot reply",
|
|
||||||
yourMessage: "Your message",
|
|
||||||
playing: "Playing audio...",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +79,6 @@ export function ca() {
|
|||||||
releaseToStop: "Allibera el dit per aturar l'enregistrament i enviar l'\u00e0udio.",
|
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.",
|
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.",
|
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...",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3182
apk/package-lock.json
generated
@@ -11,11 +11,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-native-picker/picker": "2.11.1",
|
"@react-native-picker/picker": "^2.11.4",
|
||||||
"expo": "~54.0.35",
|
"@react-native-voice/voice": "^3.2.4",
|
||||||
|
"expo": "~54.0.33",
|
||||||
"expo-av": "~16.0.8",
|
"expo-av": "~16.0.8",
|
||||||
"expo-build-properties": "~1.0.10",
|
"expo-router": "~6.0.23",
|
||||||
"expo-router": "~6.0.24",
|
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "^15.15.5",
|
||||||
"react-native-web": "~0.21.0"
|
"react-native-web": "~0.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,18 +6,4 @@ RASPBERRY_PI_PORT=8000
|
|||||||
QUIBOT_TOKEN=MY_SECRET_TOKEN
|
QUIBOT_TOKEN=MY_SECRET_TOKEN
|
||||||
|
|
||||||
# Backend server config
|
# Backend server config
|
||||||
PORT=5000
|
PORT=3000
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
2
backend/.gitignore
vendored
@@ -2,5 +2,3 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
quibot-audio-*.txt
|
|
||||||
**/quibot-audio-*.txt
|
|
||||||
|
|||||||
687
backend/package-lock.json
generated
@@ -8,7 +8,6 @@
|
|||||||
"name": "quibot-backend",
|
"name": "quibot-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -467,388 +466,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
|
||||||
"version": "1.19.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
|
||||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.14.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"hono": "^4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
|
||||||
"version": "1.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
|
||||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@hono/node-server": "^1.19.9",
|
|
||||||
"ajv": "^8.17.1",
|
|
||||||
"ajv-formats": "^3.0.1",
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"cross-spawn": "^7.0.5",
|
|
||||||
"eventsource": "^3.0.2",
|
|
||||||
"eventsource-parser": "^3.0.0",
|
|
||||||
"express": "^5.2.1",
|
|
||||||
"express-rate-limit": "^8.2.1",
|
|
||||||
"hono": "^4.11.4",
|
|
||||||
"jose": "^6.1.3",
|
|
||||||
"json-schema-typed": "^8.0.2",
|
|
||||||
"pkce-challenge": "^5.0.0",
|
|
||||||
"raw-body": "^3.0.0",
|
|
||||||
"zod": "^3.25 || ^4.0",
|
|
||||||
"zod-to-json-schema": "^3.25.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
|
||||||
"zod": "^3.25 || ^4.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@cfworker/json-schema": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"zod": {
|
|
||||||
"optional": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"negotiator": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "^3.1.2",
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"iconv-lite": "^0.7.2",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"qs": "^6.15.2",
|
|
||||||
"raw-body": "^3.0.2",
|
|
||||||
"type-is": "^2.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
|
||||||
"version": "5.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"accepts": "^2.0.0",
|
|
||||||
"body-parser": "^2.2.1",
|
|
||||||
"content-disposition": "^1.0.0",
|
|
||||||
"content-type": "^1.0.5",
|
|
||||||
"cookie": "^0.7.1",
|
|
||||||
"cookie-signature": "^1.2.1",
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"finalhandler": "^2.1.0",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.0",
|
|
||||||
"merge-descriptors": "^2.0.0",
|
|
||||||
"mime-types": "^3.0.0",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"once": "^1.4.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"proxy-addr": "^2.0.7",
|
|
||||||
"qs": "^6.14.0",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"router": "^2.2.0",
|
|
||||||
"send": "^1.1.0",
|
|
||||||
"serve-static": "^2.2.0",
|
|
||||||
"statuses": "^2.0.1",
|
|
||||||
"type-is": "^2.0.1",
|
|
||||||
"vary": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"statuses": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
|
|
||||||
"version": "1.54.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
|
||||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "^1.54.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bytes": "~3.1.2",
|
|
||||||
"http-errors": "~2.0.1",
|
|
||||||
"iconv-lite": "~0.7.0",
|
|
||||||
"unpipe": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"fresh": "^2.0.0",
|
|
||||||
"http-errors": "^2.0.1",
|
|
||||||
"mime-types": "^3.0.2",
|
|
||||||
"ms": "^2.1.3",
|
|
||||||
"on-finished": "^2.4.1",
|
|
||||||
"range-parser": "^1.2.1",
|
|
||||||
"statuses": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
|
|
||||||
"version": "2.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
|
||||||
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encodeurl": "^2.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"send": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"content-type": "^2.0.0",
|
|
||||||
"media-typer": "^1.1.0",
|
|
||||||
"mime-types": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -1035,39 +652,6 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"fast-uri": "^3.0.1",
|
|
||||||
"json-schema-traverse": "^1.0.0",
|
|
||||||
"require-from-string": "^2.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/epoberezkin"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv-formats": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ajv": "^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ajv": "^8.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"ajv": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/append-field": {
|
"node_modules/append-field": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
@@ -1263,20 +847,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-key": "^3.1.0",
|
|
||||||
"shebang-command": "^2.0.0",
|
|
||||||
"which": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -1457,27 +1027,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventsource": {
|
|
||||||
"version": "3.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
|
||||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"eventsource-parser": "^3.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eventsource-parser": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.2",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
@@ -1524,46 +1073,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
|
||||||
"version": "8.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
|
||||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ip-address": "^10.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/express-rate-limit"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"express": ">= 4.11"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-deep-equal": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fast-uri": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/fastify"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fastify"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
@@ -1748,15 +1257,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
|
||||||
"version": "4.12.26",
|
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
|
|
||||||
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -1831,15 +1331,6 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
|
||||||
"version": "10.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -1849,45 +1340,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-promise": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/isexe": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/jose": {
|
|
||||||
"version": "6.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
|
||||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/json-schema-traverse": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/json-schema-typed": {
|
|
||||||
"version": "8.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
|
||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
|
||||||
"license": "BSD-2-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -2045,15 +1503,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/openai": {
|
"node_modules/openai": {
|
||||||
"version": "6.44.0",
|
"version": "6.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
|
||||||
@@ -2081,30 +1530,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-key": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pkce-challenge": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.20.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/process-nextick-args": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -2193,64 +1624,6 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/require-from-string": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/router": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"depd": "^2.0.0",
|
|
||||||
"is-promise": "^4.0.0",
|
|
||||||
"parseurl": "^1.3.3",
|
|
||||||
"path-to-regexp": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/router/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/router/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/router/node_modules/path-to-regexp": {
|
|
||||||
"version": "8.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
|
||||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -2328,27 +1701,6 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"shebang-regex": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shebang-regex": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
@@ -2554,27 +1906,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"isexe": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-which": "bin/node-which"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
@@ -2583,24 +1914,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod-to-json-schema": {
|
|
||||||
"version": "3.25.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
|
||||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.28 || ^4"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
|||||||
@@ -1,493 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
1
backend/quibot-audio-1781783002989.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Col·la, pítalo, la ola, ola.
|
||||||
1
backend/quibot-audio-1781783032108.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hola, què tal, hola, hola, hola, hola...
|
||||||
1
backend/quibot-audio-1781783047628.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Hola, que tal, bon dia.
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/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}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -8,14 +6,6 @@ let _raspberryHost = process.env.RASPBERRY_PI_HOST ?? 'http://raspberrypi.local'
|
|||||||
let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
|
let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000;
|
||||||
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
|
let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN';
|
||||||
const APP_PORT = Number(process.env.PORT) || 5000;
|
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 getRaspberryHost = () => _raspberryHost;
|
||||||
export const getRaspberryPort = () => _raspberryPort;
|
export const getRaspberryPort = () => _raspberryPort;
|
||||||
@@ -41,16 +31,4 @@ export const getConfig = () => ({
|
|||||||
token: getToken(),
|
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;
|
export const getAppPort = () => APP_PORT;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { join } from 'path';
|
import { execFile } from 'child_process';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { rm, writeFile } from 'fs';
|
import { join } from 'path';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { whisperService } from '../services/whisper.service.js';
|
import { writeFile, unlink } from 'fs';
|
||||||
import { raspiService } from '../services/raspi.service.js';
|
import { raspiService } from '../services/raspi.service.js';
|
||||||
import { llamacppService } from '../services/llama.service.js';
|
|
||||||
const unlinkAsync = promisify(rm);
|
const execFileAsync = promisify(execFile);
|
||||||
const writeFileAsync = promisify(writeFile);
|
const writeFileAsync = promisify(writeFile);
|
||||||
|
const unlinkAsync = promisify(unlink);
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -68,9 +69,11 @@ router.post('/process/:filename', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const whisperModel = process.env.WHISPER_MODEL ?? 'base';
|
||||||
|
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
|
||||||
|
|
||||||
router.post('/upload', upload.single('file'), async (req, res) => {
|
router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
let tmpFile: string | undefined;
|
let tmpFile: string | undefined;
|
||||||
let tmpTxt: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No audio file provided' });
|
return res.status(400).json({ error: 'No audio file provided' });
|
||||||
@@ -80,24 +83,23 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||||||
tmpFile = join(tmpdir(), `quibot-audio-${Date.now()}.${ext}`);
|
tmpFile = join(tmpdir(), `quibot-audio-${Date.now()}.${ext}`);
|
||||||
await writeFileAsync(tmpFile, req.file.buffer);
|
await writeFileAsync(tmpFile, req.file.buffer);
|
||||||
|
|
||||||
const transcription = await whisperService.transcribe(tmpFile);
|
console.log(`[whisper] Model: ${whisperModel}, Language: ${whisperLanguage}, File: ${tmpFile}`);
|
||||||
console.log(transcription);
|
|
||||||
|
|
||||||
const txtPath = join(tmpdir(), `quibot-audio-${Date.now()}.txt`);
|
const { stdout, stderr } = await execFileAsync('whisper', [
|
||||||
tmpTxt = txtPath;
|
tmpFile,
|
||||||
await writeFileAsync(txtPath, transcription);
|
'--model', whisperModel,
|
||||||
|
'--language', whisperLanguage,
|
||||||
|
'--output_format', 'txt',
|
||||||
|
], { maxBuffer: 50 * 1024 * 1024 });
|
||||||
|
|
||||||
const llmResponse = await llamacppService.chatWithMcpTools(transcription).catch(
|
if (stderr) {
|
||||||
(err: unknown) => {
|
console.log(`[whisper] stderr: ${stderr}`);
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
}
|
||||||
console.error(`[audio] llama.cpp failed: ${msg}`);
|
|
||||||
return undefined;
|
const transcription = stdout.trim();
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
transcription,
|
transcription,
|
||||||
llmResponse,
|
|
||||||
originalFilename: req.file.originalname,
|
originalFilename: req.file.originalname,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -111,13 +113,6 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||||||
// ignore cleanup errors
|
// ignore cleanup errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tmpTxt) {
|
|
||||||
try {
|
|
||||||
await unlinkAsync(tmpTxt);
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
101
backend/src/controllers/text-command.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { raspiService } from '../services/raspi.service.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const commandMap = [
|
||||||
|
// Catalan forward
|
||||||
|
{ words: ['endavant', 'avança'], action: 'forward' },
|
||||||
|
// Catalan backward
|
||||||
|
{ words: ['atras', 'enrere', 'atras'], action: 'backward' },
|
||||||
|
// Catalan left
|
||||||
|
{ words: ['esquerra', 'esquerre', 'stoppa'], action: 'left' },
|
||||||
|
// Catalan right
|
||||||
|
{ words: ['dreta', 'destre'], action: 'right' },
|
||||||
|
// Stop
|
||||||
|
{ words: ['atura', 'atura', 'pare', 'stop', 'parada', 'aturar'], action: 'stop' },
|
||||||
|
// Pick up / grab
|
||||||
|
{ words: ['recull', 'pega', 'pilla', 'agafa'], action: 'pick' },
|
||||||
|
// Eject / throw
|
||||||
|
{ words: ['llança', 'tira', 'expulsa', ' llença'], action: 'eject' },
|
||||||
|
|
||||||
|
// Spanish forward
|
||||||
|
{ words: ['adelante'], action: 'forward' },
|
||||||
|
// Spanish backward
|
||||||
|
{ words: ['atras', 'atrás', 'reversa', 'al tras'], action: 'backward' },
|
||||||
|
// Spanish left
|
||||||
|
{ words: ['izquierda', 'izq'], action: 'left' },
|
||||||
|
// Spanish right
|
||||||
|
{ words: ['derecha', 'der'], action: 'right' },
|
||||||
|
// Spanish stop
|
||||||
|
{ words: ['para', 'stop', 'pare', 'deten', 'frena', 'alto'], action: 'stop' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveCommand(text: string) {
|
||||||
|
const lower = text.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
for (const entry of commandMap) {
|
||||||
|
for (const word of entry.words) {
|
||||||
|
const normalizedWord = word.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||||
|
if (lower.includes(normalizedWord)) {
|
||||||
|
return entry.action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/text', async (req, res) => {
|
||||||
|
const { text } = req.body;
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Text is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = resolveCommand(text);
|
||||||
|
if (!action) {
|
||||||
|
return res.json({ recognized: text.trim(), action: null, message: 'No motor command matched. Use: endavant, atras, esquerra, dreta, atura, recull, llança' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'forward': {
|
||||||
|
const result = await raspiService.motorStepForward();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'forward', result });
|
||||||
|
}
|
||||||
|
case 'backward': {
|
||||||
|
const result = await raspiService.motorStepBackward();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'backward', result });
|
||||||
|
}
|
||||||
|
case 'left': {
|
||||||
|
await raspiService.motorStepForward();
|
||||||
|
await new Promise(r => setTimeout(r, 600));
|
||||||
|
await raspiService.motorStop();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'turn-left', result: { status: 'turned left' } });
|
||||||
|
}
|
||||||
|
case 'right': {
|
||||||
|
await raspiService.motorStepBackward();
|
||||||
|
await new Promise(r => setTimeout(r, 600));
|
||||||
|
await raspiService.motorStop();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'turn-right', result: { status: 'turned right' } });
|
||||||
|
}
|
||||||
|
case 'stop': {
|
||||||
|
const result = await raspiService.motorStop();
|
||||||
|
return res.json({ recognized: text.trim(), action: 'stop', result });
|
||||||
|
}
|
||||||
|
case 'pick': {
|
||||||
|
return res.json({ recognized: text.trim(), action: 'pick', message: 'Pick command received' });
|
||||||
|
}
|
||||||
|
case 'eject': {
|
||||||
|
return res.json({ recognized: text.trim(), action: 'eject', message: 'Eject command received' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return res.status(500).json({
|
||||||
|
recognized: text.trim(),
|
||||||
|
action,
|
||||||
|
error: `Command execution failed: ${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -2,9 +2,6 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import router from './routes/router.js';
|
import router from './routes/router.js';
|
||||||
import { getAppPort, getConfig } from './config.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();
|
const app = express();
|
||||||
|
|
||||||
@@ -16,9 +13,6 @@ app.use('/audio', express.json());
|
|||||||
app.use('/motor', express.json());
|
app.use('/motor', express.json());
|
||||||
app.use('/commands', 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.use(router);
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
@@ -26,22 +20,6 @@ app.get('/health', (_req, res) => {
|
|||||||
res.json({ status: 'ok', settings });
|
res.json({ status: 'ok', settings });
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = app.listen(getAppPort(), async () => {
|
app.listen(getAppPort(), () => {
|
||||||
console.log(`QuiBot backend listening on port ${getAppPort()}`);
|
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'));
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -3,14 +3,14 @@ import motorController from '../controllers/motor.controller.js';
|
|||||||
import audioController from '../controllers/audio.controller.js';
|
import audioController from '../controllers/audio.controller.js';
|
||||||
import commandController from '../controllers/command.controller.js';
|
import commandController from '../controllers/command.controller.js';
|
||||||
import settingsController from '../controllers/settings.controller.js';
|
import settingsController from '../controllers/settings.controller.js';
|
||||||
import ttsController from '../controllers/tts.controller.js';
|
import textCommandController from '../controllers/text-command.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use('/motor', motorController);
|
router.use('/motor', motorController);
|
||||||
router.use('/audio', audioController);
|
router.use('/audio', audioController);
|
||||||
router.use('/commands', commandController);
|
router.use('/commands', commandController);
|
||||||
|
router.use('/commands/text', textCommandController);
|
||||||
router.use('/settings', settingsController);
|
router.use('/settings', settingsController);
|
||||||
router.use('/tts', ttsController);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
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})`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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 = [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
1
mcp/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
.venv/
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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)
|
|
||||||
10
package-lock.json
generated
@@ -5,7 +5,6 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"formdata-node": "^6.0.3",
|
|
||||||
"vue-i18n": "^11.4.5"
|
"vue-i18n": "^11.4.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -269,15 +268,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"formdata-node": "^6.0.3",
|
|
||||||
"vue-i18n": "^11.4.5"
|
"vue-i18n": "^11.4.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
quibot-web/android/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# OSX
|
||||||
|
#
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Android/IntelliJ
|
||||||
|
#
|
||||||
|
build/
|
||||||
|
.idea
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
*.hprof
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# generated inline modules
|
||||||
|
app/src/main/java/inline/
|
||||||
|
|
||||||
|
# Bundle artifacts
|
||||||
|
*.jsbundle
|
||||||
182
quibot-web/android/app/build.gradle
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
apply plugin: "com.android.application"
|
||||||
|
apply plugin: "org.jetbrains.kotlin.android"
|
||||||
|
apply plugin: "com.facebook.react"
|
||||||
|
|
||||||
|
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the configuration block to customize your React Native Android app.
|
||||||
|
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||||
|
*/
|
||||||
|
react {
|
||||||
|
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||||
|
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
|
||||||
|
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||||
|
|
||||||
|
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||||
|
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||||
|
// works correctly with Expo projects.
|
||||||
|
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||||
|
bundleCommand = "export:embed"
|
||||||
|
|
||||||
|
/* Folders */
|
||||||
|
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||||
|
// root = file("../../")
|
||||||
|
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||||
|
// reactNativeDir = file("../../node_modules/react-native")
|
||||||
|
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||||
|
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
|
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||||
|
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||||
|
|
||||||
|
/* Bundling */
|
||||||
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
|
// nodeExecutableAndArgs = ["node"]
|
||||||
|
|
||||||
|
//
|
||||||
|
// The path to the CLI configuration file. Default is empty.
|
||||||
|
// bundleConfig = file(../rn-cli.config.js)
|
||||||
|
//
|
||||||
|
// The name of the generated asset file containing your JS bundle
|
||||||
|
// bundleAssetName = "MyApplication.android.bundle"
|
||||||
|
//
|
||||||
|
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||||
|
// entryFile = file("../js/MyApplication.android.js")
|
||||||
|
//
|
||||||
|
// A list of extra flags to pass to the 'bundle' commands.
|
||||||
|
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||||
|
// extraPackagerArgs = []
|
||||||
|
|
||||||
|
/* Hermes Commands */
|
||||||
|
// The hermes compiler command to run. By default it is 'hermesc'
|
||||||
|
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||||
|
//
|
||||||
|
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||||
|
// hermesFlags = ["-O", "-output-source-map"]
|
||||||
|
|
||||||
|
/* Autolinking */
|
||||||
|
autolinkLibrariesWithApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||||
|
*/
|
||||||
|
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preferred build flavor of JavaScriptCore (JSC)
|
||||||
|
*
|
||||||
|
* For example, to use the international variant, you can use:
|
||||||
|
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||||
|
*
|
||||||
|
* The international variant includes ICU i18n library and necessary data
|
||||||
|
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||||
|
* give correct results when using with locales other than en-US. Note that
|
||||||
|
* this variant is about 6MiB larger per architecture than default.
|
||||||
|
*/
|
||||||
|
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||||
|
|
||||||
|
android {
|
||||||
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
|
||||||
|
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
namespace 'com.arandano69.quibotweb'
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'com.arandano69.quibotweb'
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
debug {
|
||||||
|
storeFile file('debug.keystore')
|
||||||
|
storePassword 'android'
|
||||||
|
keyAlias 'androiddebugkey'
|
||||||
|
keyPassword 'android'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||||
|
shrinkResources enableShrinkResources.toBoolean()
|
||||||
|
minifyEnabled enableMinifyInReleaseBuilds
|
||||||
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
|
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||||
|
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packagingOptions {
|
||||||
|
jniLibs {
|
||||||
|
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||||
|
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||||
|
// Accepts values in comma delimited lists, example:
|
||||||
|
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||||
|
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||||
|
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||||
|
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||||
|
// Trim all elements in place.
|
||||||
|
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||||
|
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||||
|
options -= ""
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||||
|
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||||
|
options.each {
|
||||||
|
android.packagingOptions[prop] += it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
|
implementation("com.facebook.react:react-android")
|
||||||
|
|
||||||
|
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||||
|
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||||
|
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||||
|
|
||||||
|
if (isGifEnabled) {
|
||||||
|
// For animated gif support
|
||||||
|
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebpEnabled) {
|
||||||
|
// For webp support
|
||||||
|
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||||
|
if (isWebpAnimatedEnabled) {
|
||||||
|
// Animated webp support
|
||||||
|
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hermesEnabled.toBoolean()) {
|
||||||
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
} else {
|
||||||
|
implementation jscFlavor
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
quibot-web/android/app/debug.keystore
Normal file
14
quibot-web/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||||
|
# You can edit the include path and order by changing the proguardFiles
|
||||||
|
# directive in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# react-native-reanimated
|
||||||
|
-keep class com.swmansion.reanimated.** { *; }
|
||||||
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
|
# Add any project specific keep options here:
|
||||||
7
quibot-web/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||||
|
</manifest>
|
||||||
26
quibot-web/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
|
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.arandano69.quibotweb
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
import com.facebook.react.ReactActivity
|
||||||
|
import com.facebook.react.ReactActivityDelegate
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
||||||
|
import expo.modules.ReactActivityDelegateWrapper
|
||||||
|
|
||||||
|
class MainActivity : ReactActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Set the theme to AppTheme BEFORE onCreate to support
|
||||||
|
// coloring the background, status bar, and navigation bar.
|
||||||
|
// This is required for expo-splash-screen.
|
||||||
|
setTheme(R.style.AppTheme);
|
||||||
|
super.onCreate(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
|
* rendering of the component.
|
||||||
|
*/
|
||||||
|
override fun getMainComponentName(): String = "main"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||||
|
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||||
|
*/
|
||||||
|
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||||
|
return ReactActivityDelegateWrapper(
|
||||||
|
this,
|
||||||
|
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||||
|
object : DefaultReactActivityDelegate(
|
||||||
|
this,
|
||||||
|
mainComponentName,
|
||||||
|
fabricEnabled
|
||||||
|
){})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the back button behavior with Android S
|
||||||
|
* where moving root activities to background instead of finishing activities.
|
||||||
|
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||||
|
*/
|
||||||
|
override fun invokeDefaultOnBackPressed() {
|
||||||
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||||
|
if (!moveTaskToBack(false)) {
|
||||||
|
// For non-root activities, use the default implementation to finish them.
|
||||||
|
super.invokeDefaultOnBackPressed()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the default back button implementation on Android S
|
||||||
|
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||||
|
super.invokeDefaultOnBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.arandano69.quibotweb
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.res.Configuration
|
||||||
|
|
||||||
|
import com.facebook.react.PackageList
|
||||||
|
import com.facebook.react.ReactApplication
|
||||||
|
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||||
|
import com.facebook.react.ReactPackage
|
||||||
|
import com.facebook.react.ReactHost
|
||||||
|
import com.facebook.react.common.ReleaseLevel
|
||||||
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||||
|
|
||||||
|
import expo.modules.ApplicationLifecycleDispatcher
|
||||||
|
import expo.modules.ExpoReactHostFactory
|
||||||
|
|
||||||
|
class MainApplication : Application(), ReactApplication {
|
||||||
|
|
||||||
|
override val reactHost: ReactHost by lazy {
|
||||||
|
ExpoReactHostFactory.getDefaultReactHost(
|
||||||
|
context = applicationContext,
|
||||||
|
packageList =
|
||||||
|
PackageList(this).packages.apply {
|
||||||
|
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||||
|
// add(MyReactNativePackage())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||||
|
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
ReleaseLevel.STABLE
|
||||||
|
}
|
||||||
|
loadReactNative(this)
|
||||||
|
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/splashscreen_background"/>
|
||||||
|
<item>
|
||||||
|
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
-->
|
||||||
|
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||||
|
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||||
|
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||||
|
>
|
||||||
|
|
||||||
|
<selector>
|
||||||
|
<!--
|
||||||
|
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||||
|
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||||
|
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||||
|
|
||||||
|
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
|
||||||
|
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||||
|
-->
|
||||||
|
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||||
|
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||||
|
</selector>
|
||||||
|
|
||||||
|
</inset>
|
||||||
BIN
quibot-web/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
quibot-web/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
4
quibot-web/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="splashscreen_background">#FFFFFF</color>
|
||||||
|
<color name="colorPrimary">#023c69</color>
|
||||||
|
</resources>
|
||||||
3
quibot-web/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">quibot-web</string>
|
||||||
|
</resources>
|
||||||
11
quibot-web/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||||
|
<item name="android:windowBackground">@drawable/splashscreen_logo</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
24
quibot-web/android/build.gradle
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath('com.android.tools.build:gradle')
|
||||||
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
|
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: "expo-root-project"
|
||||||
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
63
quibot-web/android/gradle.properties
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Enable AAPT2 PNG crunching
|
||||||
|
android.enablePngCrunchInReleaseBuilds=true
|
||||||
|
|
||||||
|
# Use this property to specify which architecture you want to build.
|
||||||
|
# You can also override it from the CLI using
|
||||||
|
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||||
|
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||||
|
|
||||||
|
# Use this property to enable support to the new architecture.
|
||||||
|
# This will allow you to use TurboModules and the Fabric render in
|
||||||
|
# your application. You should enable this flag either if you want
|
||||||
|
# to write custom TurboModules/Fabric components OR use libraries that
|
||||||
|
# are providing them.
|
||||||
|
newArchEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
|
# If set to false, you will be using JSC instead.
|
||||||
|
hermesEnabled=true
|
||||||
|
|
||||||
|
# Use this property to enable edge-to-edge display support.
|
||||||
|
# This allows your app to draw behind system bars for an immersive UI.
|
||||||
|
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||||
|
edgeToEdgeEnabled=true
|
||||||
|
|
||||||
|
# Enable GIF support in React Native images (~200 B increase)
|
||||||
|
expo.gif.enabled=true
|
||||||
|
# Enable webp support in React Native images (~85 KB increase)
|
||||||
|
expo.webp.enabled=true
|
||||||
|
# Enable animated webp support (~3.4 MB increase)
|
||||||
|
# Disabled by default because iOS doesn't support animated webp
|
||||||
|
expo.webp.animated=false
|
||||||
|
|
||||||
|
# Enable network inspector
|
||||||
|
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||||
|
|
||||||
|
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||||
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
|
expo.inlineModules.watchedDirectories=[]
|
||||||
BIN
quibot-web/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
quibot-web/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
quibot-web/android/gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
98
quibot-web/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
@REM
|
||||||
|
@REM This source code is licensed under the MIT license found in the
|
||||||
|
@REM LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
39
quibot-web/android/settings.gradle
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
pluginManagement {
|
||||||
|
def reactNativeGradlePlugin = new File(
|
||||||
|
providers.exec {
|
||||||
|
workingDir(rootDir)
|
||||||
|
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||||
|
}.standardOutput.asText.get().trim()
|
||||||
|
).getParentFile().absolutePath
|
||||||
|
includeBuild(reactNativeGradlePlugin)
|
||||||
|
|
||||||
|
def expoPluginsPath = new File(
|
||||||
|
providers.exec {
|
||||||
|
workingDir(rootDir)
|
||||||
|
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||||
|
}.standardOutput.asText.get().trim(),
|
||||||
|
"../android/expo-gradle-plugin"
|
||||||
|
).absolutePath
|
||||||
|
includeBuild(expoPluginsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.facebook.react.settings")
|
||||||
|
id("expo-autolinking-settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
|
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||||
|
ex.autolinkLibrariesFromCommand()
|
||||||
|
} else {
|
||||||
|
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expoAutolinking.useExpoModules()
|
||||||
|
|
||||||
|
rootProject.name = 'quibot-web'
|
||||||
|
|
||||||
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
|
|
||||||
|
include ':app'
|
||||||
|
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||||
5
quibot-web/app.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"package": "com.arandano69.quibotweb"
|
||||||
|
}
|
||||||
|
}
|
||||||
3531
quibot-web/package-lock.json
generated
@@ -10,9 +10,14 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"expo-speech-recognition": "^56.0.1",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"react-native-svg": "^15.15.5",
|
"react-native-svg": "^15.15.5",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4",
|
||||||
}
|
"expo": "~56.0.12",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-native": "0.85.3"
|
||||||
|
},
|
||||||
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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)
|
|
||||||
2
raspi/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
venv/
|
||||||
160
raspi/blocks.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
blocks.py — Lectura del sensor de color TCS34725 i servo d'expulsió de blocs.
|
||||||
|
Equivalent a blocks.cpp del codi Arduino/ESP32.
|
||||||
|
|
||||||
|
Requereix /boot/config.txt:
|
||||||
|
dtoverlay=i2c-gpio,bus=4,i2c_gpio_sda=22,i2c_gpio_scl=27
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pigpio
|
||||||
|
import adafruit_extended_bus
|
||||||
|
import adafruit_tcs34725
|
||||||
|
|
||||||
|
from pins import SERVO_PWM
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# IDs de color
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
BK = 0 # Negre (no reconegut)
|
||||||
|
RD = 1 # Vermell → avançar
|
||||||
|
GN = 2 # Verd → girar dreta
|
||||||
|
BU = 3 # Blau → girar esquerra
|
||||||
|
YE = 4 # Groc → xuclar líquid
|
||||||
|
OG = 5 # Taronja → buidar líquid
|
||||||
|
VT = 6 # Violeta → sorpresa
|
||||||
|
|
||||||
|
NUM_COLORS = 7
|
||||||
|
|
||||||
|
# Taula de colors de referència (valors RGB 0–255, calibrats amb el sensor)
|
||||||
|
_COLORS = [
|
||||||
|
{"name": "BK", "r": 80, "g": 80, "b": 80},
|
||||||
|
{"name": "RD", "r": 202, "g": 32, "b": 34},
|
||||||
|
{"name": "GN", "r": 107, "g": 90, "b": 57},
|
||||||
|
{"name": "BU", "r": 104, "g": 83, "b": 66},
|
||||||
|
{"name": "YE", "r": 150, "g": 69, "b": 33},
|
||||||
|
{"name": "OG", "r": 185, "g": 44, "b": 32},
|
||||||
|
{"name": "VT", "r": 129, "g": 70, "b": 55},
|
||||||
|
]
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Paràmetres del servo
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
# Valors en µs de pulse width (pigpio set_servo_pulsewidth).
|
||||||
|
# Conversió des de l'ESP32 (16 bits, 50Hz): valor/65535 * 20000µs
|
||||||
|
# MIN = 3277/65535 * 20000 ≈ 1000µs
|
||||||
|
# MAX = 8000/65535 * 20000 ≈ 2440µs
|
||||||
|
# EJECT= 6450/65535 * 20000 ≈ 1968µs
|
||||||
|
MIN_SERVO_US = 1000 # µs → ~0°
|
||||||
|
MAX_SERVO_US = 2440 # µs → ~180°
|
||||||
|
OPEN_POSITION = 2440 # µs (equivalent a 8000 ESP32)
|
||||||
|
EJECT_POSITION = 1968 # µs (equivalent a 6450 ESP32)
|
||||||
|
_INCREMENT_US = 3 # µs per iteració de 1ms (equivalent a increment de 10 ESP32)
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Instàncies globals
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_pi: pigpio.pi = None
|
||||||
|
_color_sensor = None
|
||||||
|
_current_servo_pos: int = OPEN_POSITION
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Setup
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def blocks_setup(pi: pigpio.pi):
|
||||||
|
"""
|
||||||
|
Inicialitza el servo i el sensor de color TCS34725.
|
||||||
|
El servo arranca en OPEN_POSITION.
|
||||||
|
"""
|
||||||
|
global _pi, _color_sensor, _current_servo_pos
|
||||||
|
|
||||||
|
_pi = pi
|
||||||
|
|
||||||
|
# Servo
|
||||||
|
pi.set_mode(SERVO_PWM, pigpio.OUTPUT)
|
||||||
|
_current_servo_pos = OPEN_POSITION
|
||||||
|
pi.set_servo_pulsewidth(SERVO_PWM, _current_servo_pos)
|
||||||
|
|
||||||
|
# Sensor de color TCS34725 via bus I2C 4 (bit-bang GPIO22=SDA, GPIO27=SCL)
|
||||||
|
i2c = adafruit_extended_bus.ExtendedI2C(4)
|
||||||
|
_color_sensor = adafruit_tcs34725.TCS34725(i2c)
|
||||||
|
_color_sensor.integration_time = 50 # ms
|
||||||
|
_color_sensor.gain = 4 # 4x (equivalent a TCS34725::Gain::X04)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Servo
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def servo_move_to(target_us: int):
|
||||||
|
"""
|
||||||
|
Mou el servo fins a target_us de forma suau.
|
||||||
|
Incrementa/decrementa _INCREMENT_US cada mil·lisegon.
|
||||||
|
"""
|
||||||
|
global _current_servo_pos
|
||||||
|
|
||||||
|
if not (MIN_SERVO_US <= target_us <= MAX_SERVO_US):
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if _current_servo_pos < target_us - _INCREMENT_US:
|
||||||
|
_current_servo_pos += _INCREMENT_US
|
||||||
|
elif _current_servo_pos > target_us + _INCREMENT_US:
|
||||||
|
_current_servo_pos -= _INCREMENT_US
|
||||||
|
else:
|
||||||
|
_current_servo_pos = target_us
|
||||||
|
|
||||||
|
_pi.set_servo_pulsewidth(SERVO_PWM, _current_servo_pos)
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
if _current_servo_pos == target_us:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Sensor de color
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _calc_colors_difference(measured: tuple, reference: dict) -> int:
|
||||||
|
"""Distància Manhattan entre el color mesurat i un color de referència."""
|
||||||
|
return (abs(measured[0] - reference["r"]) +
|
||||||
|
abs(measured[1] - reference["g"]) +
|
||||||
|
abs(measured[2] - reference["b"]))
|
||||||
|
|
||||||
|
|
||||||
|
def read_color_raw() -> tuple:
|
||||||
|
"""Retorna (r, g, b) en bytes 0-255 sense classificació. Útil per calibrar."""
|
||||||
|
try:
|
||||||
|
return _color_sensor.color_rgb_bytes
|
||||||
|
except Exception:
|
||||||
|
return (0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def read_block_color() -> int:
|
||||||
|
"""
|
||||||
|
Llegeix el color del bloc des del sensor TCS34725.
|
||||||
|
Compara contra la taula de referència per distància Manhattan.
|
||||||
|
Retorna l'ID del color més proper, o BK si cap supera el llindar.
|
||||||
|
"""
|
||||||
|
MAX_DIFFERENCE = 15
|
||||||
|
|
||||||
|
try:
|
||||||
|
r, g, b = _color_sensor.color_rgb_bytes
|
||||||
|
except Exception:
|
||||||
|
return BK
|
||||||
|
|
||||||
|
min_difference = MAX_DIFFERENCE
|
||||||
|
min_diff_color = BK
|
||||||
|
|
||||||
|
for color_id, ref in enumerate(_COLORS):
|
||||||
|
diff = _calc_colors_difference((r, g, b), ref)
|
||||||
|
if diff < min_difference:
|
||||||
|
min_difference = diff
|
||||||
|
min_diff_color = color_id
|
||||||
|
|
||||||
|
return min_diff_color
|
||||||
273
raspi/eyes.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
eyes.py — Control de les matrius LED 8x8 RGB WS2811 (ulls del robot).
|
||||||
|
Equivalent a eyes.cpp del codi Arduino/ESP32.
|
||||||
|
|
||||||
|
FastLED → pigpio waveforms (GPIO26, qualsevol pin).
|
||||||
|
|
||||||
|
REQUISIT: iniciar el dimoni pigpio amb resolució d'1µs:
|
||||||
|
sudo pigpiod -s 1
|
||||||
|
Si s'inicia sense -s 1 (defecte 5µs), els LEDs no funcionaran correctament.
|
||||||
|
|
||||||
|
Si en el futur es fa una modificació hardware (GPIO26 → GPIO18 o GPIO21),
|
||||||
|
es pot substituir _send_ws2811() per rpi_ws281x sense canviar cap altra funció.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import pigpio
|
||||||
|
|
||||||
|
from pins import LED_DATA
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Constants
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
ROW_NUM = 8
|
||||||
|
COL_NUM = 8
|
||||||
|
NUM_LEDS = ROW_NUM * COL_NUM * 2 # 128 LEDs (2 matrius 8x8)
|
||||||
|
|
||||||
|
MAX_BR = 170 # Brillantor màxima del parpelleig (0–255)
|
||||||
|
MIN_BR = 80 # Brillantor mínima del parpelleig
|
||||||
|
|
||||||
|
# Colors predefinits (R, G, B)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
RED = (255, 0, 0)
|
||||||
|
GREEN = ( 0, 255, 0)
|
||||||
|
BLUE = ( 0, 0, 255)
|
||||||
|
YELLOW = (255, 200, 0)
|
||||||
|
ORANGE = (255, 80, 0)
|
||||||
|
PURPLE = (180, 0, 255)
|
||||||
|
CYAN = ( 0, 255, 255) # Color del mode gestos
|
||||||
|
BLACK = ( 0, 0, 0)
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Formes dels ulls (índexs dels LEDs actius)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
class EyeShape:
|
||||||
|
"""Conjunt de LEDs que formen una expressió dels ulls."""
|
||||||
|
def __init__(self, leds: list):
|
||||||
|
self.leds = leds
|
||||||
|
self.len = len(leds)
|
||||||
|
|
||||||
|
|
||||||
|
EYES_OPEN = EyeShape([
|
||||||
|
102, 89, 38, 25, 106, 101, 90, 85, 42, 37, 26, 21,
|
||||||
|
107, 100, 91, 84, 43, 36, 27, 20, 108, 99, 92, 83,
|
||||||
|
44, 35, 28, 19, 109, 98, 93, 82, 45, 34, 29, 18,
|
||||||
|
97, 94, 33, 30
|
||||||
|
])
|
||||||
|
|
||||||
|
EYES_FW = EyeShape([
|
||||||
|
103, 88, 39, 24, 105, 102, 89, 86, 41, 38, 25, 22,
|
||||||
|
117, 106, 101, 90, 85, 74, 53, 42, 37, 26, 21, 10,
|
||||||
|
123, 116, 100, 91, 75, 68, 59, 52, 36, 27, 11, 4,
|
||||||
|
99, 92, 35, 28, 98, 93, 34, 29, 97, 94, 33, 30, 96,
|
||||||
|
95, 32, 31
|
||||||
|
])
|
||||||
|
|
||||||
|
EYES_DOWN = EyeShape([97, 94, 33, 30])
|
||||||
|
|
||||||
|
# Nova forma per al mode gestos: marc extern dels dos ulls (expressió "atenta")
|
||||||
|
EYES_GESTURE = EyeShape([
|
||||||
|
96, 97, 98, 99, 100, 101, 102, 103,
|
||||||
|
104, 111, 112, 119, 120, 127,
|
||||||
|
31, 32, 33, 34, 35, 36, 37, 38, 39,
|
||||||
|
0, 7, 8, 15, 16, 23
|
||||||
|
])
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Estat global
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_pi: pigpio.pi = None
|
||||||
|
_leds = [[0, 0, 0] for _ in range(NUM_LEDS)]
|
||||||
|
_brightness = MAX_BR
|
||||||
|
_leds_lock = threading.Lock()
|
||||||
|
|
||||||
|
_update_stop = threading.Event()
|
||||||
|
_update_thread: threading.Thread = None
|
||||||
|
|
||||||
|
# Màscara GPIO per a les waveforms de pigpio
|
||||||
|
_GPIO_MASK: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# WS2811 via pigpio waveforms
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _send_ws2811(data: bytes):
|
||||||
|
"""
|
||||||
|
Envia dades RGB als LEDs WS2811 via pigpio waveforms.
|
||||||
|
Ordre de color: GRB (igual que FastLED amb WS2811, GRB).
|
||||||
|
Timing a 1µs de resolució (requereix sudo pigpiod -s 1):
|
||||||
|
- Bit 0: 1µs HIGH + 2µs LOW (spec: 0.5µs + 2.0µs)
|
||||||
|
- Bit 1: 2µs HIGH + 1µs LOW (spec: 1.2µs + 1.3µs)
|
||||||
|
- Reset: 80µs LOW
|
||||||
|
"""
|
||||||
|
pulses = []
|
||||||
|
for byte_val in data:
|
||||||
|
for bit in range(7, -1, -1):
|
||||||
|
if byte_val & (1 << bit):
|
||||||
|
pulses.append(pigpio.pulse(_GPIO_MASK, 0, 2))
|
||||||
|
pulses.append(pigpio.pulse(0, _GPIO_MASK, 1))
|
||||||
|
else:
|
||||||
|
pulses.append(pigpio.pulse(_GPIO_MASK, 0, 1))
|
||||||
|
pulses.append(pigpio.pulse(0, _GPIO_MASK, 2))
|
||||||
|
pulses.append(pigpio.pulse(0, _GPIO_MASK, 80)) # reset
|
||||||
|
|
||||||
|
_pi.wave_add_new()
|
||||||
|
_pi.wave_add_generic(pulses)
|
||||||
|
wid = _pi.wave_create()
|
||||||
|
if wid >= 0:
|
||||||
|
_pi.wave_send_once(wid)
|
||||||
|
while _pi.wave_tx_busy():
|
||||||
|
pass
|
||||||
|
_pi.wave_delete(wid)
|
||||||
|
|
||||||
|
|
||||||
|
def _eyes_show(brightness: int):
|
||||||
|
"""Renderitza l'estat actual de _leds amb la brillantor indicada."""
|
||||||
|
data = bytearray(NUM_LEDS * 3)
|
||||||
|
scale = brightness / 255
|
||||||
|
for i, (r, g, b) in enumerate(_leds):
|
||||||
|
data[i * 3 + 0] = int(g * scale) # WS2811 GRB: primer G
|
||||||
|
data[i * 3 + 1] = int(r * scale)
|
||||||
|
data[i * 3 + 2] = int(b * scale)
|
||||||
|
_send_ws2811(bytes(data))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Setup i cleanup
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def eyes_setup(pi: pigpio.pi):
|
||||||
|
"""Inicialitza el GPIO i arrenca el thread de parpelleig."""
|
||||||
|
global _pi, _GPIO_MASK, _update_thread
|
||||||
|
|
||||||
|
_pi = pi
|
||||||
|
_GPIO_MASK = 1 << LED_DATA
|
||||||
|
|
||||||
|
pi.set_mode(LED_DATA, pigpio.OUTPUT)
|
||||||
|
pi.write(LED_DATA, 0)
|
||||||
|
|
||||||
|
_update_stop.clear()
|
||||||
|
_update_thread = threading.Thread(
|
||||||
|
target=_task_update_leds, daemon=True, name="eyes"
|
||||||
|
)
|
||||||
|
_update_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def eyes_cleanup():
|
||||||
|
"""Atura el thread de parpelleig i apaga els LEDs."""
|
||||||
|
_update_stop.set()
|
||||||
|
if _update_thread:
|
||||||
|
_update_thread.join(timeout=1.0)
|
||||||
|
with _leds_lock:
|
||||||
|
for i in range(NUM_LEDS):
|
||||||
|
_leds[i] = [0, 0, 0]
|
||||||
|
_eyes_show(255)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Thread de parpelleig (equivalent a task_update_leds del FreeRTOS)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _task_update_leds():
|
||||||
|
"""
|
||||||
|
Bucle continu que fa respirar la brillantor dels ulls.
|
||||||
|
Equivalent a task_update_leds() del FreeRTOS.
|
||||||
|
"""
|
||||||
|
global _brightness
|
||||||
|
going_up = False # Al C++ comença a MAX_BR i baixa
|
||||||
|
_brightness = MAX_BR
|
||||||
|
|
||||||
|
while not _update_stop.is_set():
|
||||||
|
if going_up:
|
||||||
|
if _brightness < MAX_BR:
|
||||||
|
_brightness += 2
|
||||||
|
else:
|
||||||
|
going_up = False
|
||||||
|
else:
|
||||||
|
if _brightness > MIN_BR:
|
||||||
|
_brightness -= 2
|
||||||
|
else:
|
||||||
|
going_up = True
|
||||||
|
|
||||||
|
with _leds_lock:
|
||||||
|
_eyes_show(_brightness)
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Animacions (equivalent a les funcions de eyes.cpp)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def eyes_turn_off():
|
||||||
|
"""Apaga tots els LEDs amb un fos progressiu."""
|
||||||
|
for _ in range(50):
|
||||||
|
with _leds_lock:
|
||||||
|
for i in range(NUM_LEDS):
|
||||||
|
r, g, b = _leds[i]
|
||||||
|
_leds[i] = [int(r * 245 / 255),
|
||||||
|
int(g * 245 / 255),
|
||||||
|
int(b * 245 / 255)]
|
||||||
|
time.sleep(0.01)
|
||||||
|
with _leds_lock:
|
||||||
|
for i in range(NUM_LEDS):
|
||||||
|
_leds[i] = [0, 0, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def eyes_turn_on(shape: EyeShape, color: tuple,
|
||||||
|
repeat: int = 1, forward: bool = True):
|
||||||
|
"""
|
||||||
|
Encén els LEDs d'una forma un per un, amb animació.
|
||||||
|
shape: forma a dibuixar (EYES_OPEN, EYES_FW, EYES_DOWN…)
|
||||||
|
color: color RGB com a tupla (r, g, b)
|
||||||
|
repeat: nombre de vegades que es repeteix l'animació
|
||||||
|
forward: True = ordre normal, False = ordre invers
|
||||||
|
"""
|
||||||
|
r, g, b = color
|
||||||
|
for rep in range(repeat):
|
||||||
|
eyes_turn_off()
|
||||||
|
for i in range(shape.len):
|
||||||
|
idx = shape.leds[i if forward else (shape.len - 1 - i)]
|
||||||
|
with _leds_lock:
|
||||||
|
_leds[idx] = [r, g, b]
|
||||||
|
time.sleep(0.008)
|
||||||
|
|
||||||
|
if rep < repeat - 1:
|
||||||
|
for i in range(shape.len):
|
||||||
|
idx = shape.leds[i if forward else (shape.len - 1 - i)]
|
||||||
|
with _leds_lock:
|
||||||
|
_leds[idx] = [0, 0, 0]
|
||||||
|
time.sleep(0.008)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Animacions noves — mode gestos (TFG)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def eyes_gesture_mode_on():
|
||||||
|
"""
|
||||||
|
Animació d'activació del mode gestos.
|
||||||
|
Parpelleig doble en cian per indicar que el robot escolta gestos.
|
||||||
|
"""
|
||||||
|
eyes_turn_on(EYES_OPEN, CYAN, repeat=2)
|
||||||
|
|
||||||
|
|
||||||
|
def eyes_gesture_mode_off():
|
||||||
|
"""
|
||||||
|
Animació de desactivació del mode gestos.
|
||||||
|
Torna als ulls oberts en blanc.
|
||||||
|
"""
|
||||||
|
eyes_turn_off()
|
||||||
|
eyes_turn_on(EYES_OPEN, WHITE)
|
||||||
|
|
||||||
|
|
||||||
|
def eyes_listening():
|
||||||
|
"""
|
||||||
|
Expressió "escoltant": marc extern dels ulls en cian.
|
||||||
|
Es mostra mentre el robot espera un gest.
|
||||||
|
"""
|
||||||
|
eyes_turn_on(EYES_GESTURE, CYAN)
|
||||||
238
raspi/gesture.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
gesture.py — Lectura del sensor de gestos PAJ7620U2 via I2C raw (smbus2).
|
||||||
|
Equivalent a gesture.cpp del codi Arduino/ESP32.
|
||||||
|
|
||||||
|
No hi ha pin INT disponible al PCB → polling cada 50ms en un thread.
|
||||||
|
Bus I2C 3 (GPIO2=SDA, GPIO1=SCL, compartit amb VL53L0X i ADS1115).
|
||||||
|
|
||||||
|
Llibreria C++ equivalent: RevEng_PAJ7620 (Aaron S. Crandall)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import smbus2
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# IDs de gest
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
GS_NONE = 0
|
||||||
|
GS_FORWARD = 1
|
||||||
|
GS_LEFT = 2
|
||||||
|
GS_RIGHT = 3
|
||||||
|
GS_UP = 4
|
||||||
|
GS_DOWN = 5
|
||||||
|
GS_CLOCKWISE = 6
|
||||||
|
GS_ANTICLOCKWISE = 7
|
||||||
|
GS_WAVE = 8
|
||||||
|
|
||||||
|
# Àlies per al quibot.py
|
||||||
|
GS_CW = GS_CLOCKWISE
|
||||||
|
GS_CCW = GS_ANTICLOCKWISE
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Registres PAJ7620U2
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_PAJ7620_ADDR = 0x73
|
||||||
|
_REG_BANK_SEL = 0xEF # Registre de selecció de banc (0x00=banc0, 0x01=banc1)
|
||||||
|
_REG_PART_ID_LSB = 0x00 # Ha de retornar 0x20
|
||||||
|
_REG_PART_ID_MSB = 0x01 # Ha de retornar 0x76
|
||||||
|
_REG_GESTURE_0 = 0x43 # Bits 0–7: Right, Left, Up, Down, Forward, Backward, CW, CCW
|
||||||
|
_REG_GESTURE_1 = 0x44 # Bit 0: Wave
|
||||||
|
|
||||||
|
# Bits de gest al registre 0x43
|
||||||
|
_BIT_RIGHT = 0x01
|
||||||
|
_BIT_LEFT = 0x02
|
||||||
|
_BIT_UP = 0x04
|
||||||
|
_BIT_DOWN = 0x08
|
||||||
|
_BIT_FORWARD = 0x10
|
||||||
|
_BIT_BACKWARD = 0x20
|
||||||
|
_BIT_CW = 0x40
|
||||||
|
_BIT_CCW = 0x80
|
||||||
|
|
||||||
|
# Bit de gest al registre 0x44
|
||||||
|
_BIT_WAVE = 0x01
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Seqüències d'inicialització
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_INIT_BANK0 = [
|
||||||
|
(0x32, 0x29), (0x33, 0x01), (0x34, 0x00), (0x35, 0x01), (0x36, 0x00),
|
||||||
|
(0x37, 0x07), (0x38, 0x17), (0x39, 0x06), (0x3A, 0x12), (0x3F, 0x00),
|
||||||
|
(0x40, 0x02), (0x41, 0xFF), (0x42, 0x01), (0x46, 0x2D), (0x47, 0x0F),
|
||||||
|
(0x48, 0x3C), (0x49, 0x00), (0x4A, 0x1E), (0x4B, 0x00), (0x4C, 0x20),
|
||||||
|
(0x4D, 0x00), (0x4E, 0x1A), (0x4F, 0x14), (0x50, 0x00), (0x51, 0x10),
|
||||||
|
(0x52, 0x00), (0x5C, 0x02), (0x5D, 0x00), (0x5E, 0x10), (0x5F, 0x3F),
|
||||||
|
(0x60, 0x27), (0x61, 0x28), (0x62, 0x00), (0x63, 0x03), (0x64, 0xF7),
|
||||||
|
(0x65, 0x03), (0x66, 0xD9), (0x67, 0x03), (0x68, 0x01), (0x69, 0xC8),
|
||||||
|
(0x6A, 0x40), (0x6D, 0x04), (0x6E, 0x00), (0x6F, 0x00), (0x70, 0x80),
|
||||||
|
(0x71, 0x00), (0x72, 0x00), (0x73, 0x00), (0x74, 0xF0), (0x75, 0x00),
|
||||||
|
(0x80, 0x42), (0x81, 0x44), (0x82, 0x04), (0x83, 0x20), (0x84, 0x20),
|
||||||
|
(0x85, 0x00), (0x86, 0x10), (0x87, 0x00), (0x88, 0x05), (0x89, 0x18),
|
||||||
|
(0x8A, 0x10), (0x8B, 0x01), (0x8C, 0x37), (0x8D, 0x00), (0x8E, 0xF0),
|
||||||
|
(0x8F, 0x81), (0x90, 0x06), (0x91, 0x06), (0x92, 0x1E), (0x93, 0x0D),
|
||||||
|
(0x94, 0x0A), (0x95, 0x0A), (0x96, 0x0C), (0x97, 0x05), (0x98, 0x0A),
|
||||||
|
(0x99, 0x41), (0x9A, 0x14), (0x9B, 0x0A), (0x9C, 0x3F), (0x9D, 0x33),
|
||||||
|
(0x9E, 0xAE), (0x9F, 0xF9), (0xA0, 0x48), (0xA1, 0x13), (0xA2, 0x10),
|
||||||
|
(0xA3, 0x08), (0xA4, 0x30), (0xA5, 0x19), (0xA6, 0x10), (0xA7, 0x08),
|
||||||
|
(0xA8, 0x24), (0xA9, 0x04), (0xAA, 0x1E), (0xAB, 0x1E), (0xCC, 0x19),
|
||||||
|
(0xCD, 0x0B), (0xCE, 0x13), (0xCF, 0x64), (0xD0, 0x21), (0xD1, 0x0F),
|
||||||
|
(0xD2, 0x88), (0xE0, 0x01), (0xE1, 0x04), (0xE2, 0x41), (0xE3, 0xD6),
|
||||||
|
(0xE4, 0x00), (0xE5, 0x0C), (0xE6, 0x0A), (0xE7, 0x00), (0xE8, 0x00),
|
||||||
|
(0xE9, 0x00), (0xEE, 0x07),
|
||||||
|
]
|
||||||
|
|
||||||
|
_INIT_BANK1 = [
|
||||||
|
(0x00, 0x1E), (0x01, 0x1E), (0x02, 0x0F), (0x03, 0x10), (0x04, 0x02),
|
||||||
|
(0x05, 0x00), (0x06, 0xB0), (0x07, 0x04), (0x08, 0x0D), (0x09, 0x0E),
|
||||||
|
(0x0A, 0x9C), (0x0B, 0x04), (0x0C, 0x05), (0x0D, 0x0F), (0x0E, 0x02),
|
||||||
|
(0x0F, 0x12), (0x10, 0x02), (0x11, 0x02), (0x12, 0x00), (0x13, 0x01),
|
||||||
|
(0x14, 0x05), (0x15, 0x07), (0x16, 0x05), (0x17, 0x07), (0x18, 0x01),
|
||||||
|
(0x19, 0x04), (0x1A, 0x05), (0x1B, 0x0C), (0x1C, 0x2A), (0x1D, 0x01),
|
||||||
|
(0x1E, 0x00), (0x21, 0x00), (0x22, 0x00), (0x23, 0x00), (0x25, 0x01),
|
||||||
|
(0x26, 0x00), (0x27, 0x39), (0x28, 0x7F), (0x29, 0x08), (0x30, 0x03),
|
||||||
|
(0x31, 0x00), (0x32, 0x1A), (0x33, 0x1A), (0x34, 0x07), (0x35, 0x07),
|
||||||
|
(0x36, 0x01), (0x37, 0xFF), (0x38, 0x36), (0x39, 0x07), (0x3A, 0x00),
|
||||||
|
(0x3E, 0xFF), (0x3F, 0x00), (0x40, 0x77), (0x41, 0x40), (0x42, 0x00),
|
||||||
|
(0x43, 0x30), (0x44, 0xA0), (0x45, 0x5C), (0x46, 0x00), (0x47, 0x00),
|
||||||
|
(0x48, 0x58), (0x4A, 0x1E), (0x4B, 0x1E), (0x4C, 0x00), (0x4D, 0x00),
|
||||||
|
(0x4E, 0xA0), (0x4F, 0x80), (0x50, 0x00), (0x51, 0x00), (0x52, 0x00),
|
||||||
|
(0x53, 0x00), (0x54, 0x00), (0x57, 0x80), (0x59, 0x10), (0x5A, 0x08),
|
||||||
|
(0x5B, 0x94), (0x5C, 0xE8), (0x5D, 0x08), (0x5E, 0x3D), (0x5F, 0x99),
|
||||||
|
(0x60, 0x45), (0x61, 0x40), (0x63, 0x2D), (0x64, 0x02), (0x65, 0x96),
|
||||||
|
(0x66, 0x00), (0x67, 0x97), (0x68, 0x01), (0x69, 0xCD), (0x6A, 0x01),
|
||||||
|
(0x6B, 0xB0), (0x6C, 0x04), (0x6D, 0x2C), (0x6E, 0x01), (0x6F, 0x32),
|
||||||
|
(0x71, 0x00), (0x72, 0x01), (0x73, 0x35), (0x74, 0x00), (0x75, 0x33),
|
||||||
|
(0x76, 0x31), (0x77, 0x01), (0x7C, 0x84), (0x7D, 0x03), (0x7E, 0x01),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Estat global
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_bus: smbus2.SMBus = None
|
||||||
|
_gesture: int = GS_NONE
|
||||||
|
_gesture_lock = threading.Lock()
|
||||||
|
_poll_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Helpers I2C
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _select_bank(bank: int):
|
||||||
|
_bus.write_byte_data(_PAJ7620_ADDR, _REG_BANK_SEL, bank)
|
||||||
|
|
||||||
|
def _write(reg: int, val: int):
|
||||||
|
_bus.write_byte_data(_PAJ7620_ADDR, reg, val)
|
||||||
|
|
||||||
|
def _read(reg: int) -> int:
|
||||||
|
return _bus.read_byte_data(_PAJ7620_ADDR, reg)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Setup
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def gesture_setup():
|
||||||
|
"""
|
||||||
|
Inicialitza el PAJ7620U2 via I2C raw (smbus2, bus 3).
|
||||||
|
Arrenca el thread de polling (equivalent al ISR del C++).
|
||||||
|
"""
|
||||||
|
global _bus
|
||||||
|
|
||||||
|
_bus = smbus2.SMBus(3)
|
||||||
|
|
||||||
|
# Desperta el sensor (primer accés I2C)
|
||||||
|
try:
|
||||||
|
_bus.write_byte(_PAJ7620_ADDR, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.001) # 700µs d'espera per wake-up
|
||||||
|
|
||||||
|
# Verifica ID del dispositiu
|
||||||
|
_select_bank(0)
|
||||||
|
id_lsb = _read(_REG_PART_ID_LSB)
|
||||||
|
id_msb = _read(_REG_PART_ID_MSB)
|
||||||
|
if id_lsb != 0x20 or id_msb != 0x76:
|
||||||
|
print(f"ERROR: PAJ7620U2 NOT FOUND (ID: {id_msb:02X}{id_lsb:02X})")
|
||||||
|
else:
|
||||||
|
print("Gesture sensor init OK")
|
||||||
|
|
||||||
|
# Escriu registres d'inicialització (banc 0)
|
||||||
|
_select_bank(0)
|
||||||
|
for reg, val in _INIT_BANK0:
|
||||||
|
_write(reg, val)
|
||||||
|
|
||||||
|
# Escriu registres d'inicialització (banc 1)
|
||||||
|
_select_bank(1)
|
||||||
|
for reg, val in _INIT_BANK1:
|
||||||
|
_write(reg, val)
|
||||||
|
|
||||||
|
# Torna al banc 0 per a la lectura de gestos
|
||||||
|
_select_bank(0)
|
||||||
|
|
||||||
|
# Arrenca el thread de polling
|
||||||
|
_poll_stop.clear()
|
||||||
|
threading.Thread(target=_poll_loop, daemon=True, name="gesture").start()
|
||||||
|
|
||||||
|
|
||||||
|
def gesture_cleanup():
|
||||||
|
"""Atura el polling i tanca el bus I2C."""
|
||||||
|
_poll_stop.set()
|
||||||
|
if _bus:
|
||||||
|
_bus.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Thread de polling (equivalent al ISR + flag del C++)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _poll_loop():
|
||||||
|
"""
|
||||||
|
Llegeix els registres de gest cada 50ms.
|
||||||
|
Equivalent a on_gesture_interrupt() + gesture_available flag del C++.
|
||||||
|
"""
|
||||||
|
global _gesture
|
||||||
|
|
||||||
|
while not _poll_stop.is_set():
|
||||||
|
try:
|
||||||
|
g0 = _read(_REG_GESTURE_0)
|
||||||
|
g1 = _read(_REG_GESTURE_1)
|
||||||
|
|
||||||
|
detected = GS_NONE
|
||||||
|
if g0 & _BIT_FORWARD: detected = GS_FORWARD
|
||||||
|
elif g0 & _BIT_LEFT: detected = GS_LEFT
|
||||||
|
elif g0 & _BIT_RIGHT: detected = GS_RIGHT
|
||||||
|
elif g0 & _BIT_UP: detected = GS_UP
|
||||||
|
elif g0 & _BIT_DOWN: detected = GS_DOWN
|
||||||
|
elif g0 & _BIT_CW: detected = GS_CLOCKWISE
|
||||||
|
elif g0 & _BIT_CCW: detected = GS_ANTICLOCKWISE
|
||||||
|
elif g1 & _BIT_WAVE: detected = GS_WAVE
|
||||||
|
|
||||||
|
if detected != GS_NONE:
|
||||||
|
with _gesture_lock:
|
||||||
|
_gesture = detected
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass # error I2C puntual → ignora i continua
|
||||||
|
|
||||||
|
time.sleep(0.05) # 50ms de polling (20Hz)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Lectura de gest
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def read_gesture() -> int:
|
||||||
|
"""
|
||||||
|
Retorna l'últim gest detectat i el reseteja a GS_NONE.
|
||||||
|
No bloquejant — equivalent a read_gesture() del C++.
|
||||||
|
"""
|
||||||
|
global _gesture
|
||||||
|
with _gesture_lock:
|
||||||
|
gest = _gesture
|
||||||
|
_gesture = GS_NONE
|
||||||
|
return gest
|
||||||
210
raspi/main.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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()
|
||||||
619
raspi/motion.py
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
"""
|
||||||
|
motion.py — Control de motors (rodes, braços, xeringa), sensor de distància
|
||||||
|
VL53L0X i seguidor de línia TCRT5000 via ADS1115.
|
||||||
|
Equivalent a motion.cpp del codi Arduino/ESP32.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import pigpio
|
||||||
|
import adafruit_extended_bus
|
||||||
|
import adafruit_vl53l0x
|
||||||
|
import adafruit_ads1x15.ads1115 as ADS
|
||||||
|
from adafruit_ads1x15.analog_in import AnalogIn
|
||||||
|
|
||||||
|
from pins import (
|
||||||
|
STEP_R_W, DIR_R_W, STEP_L_W, DIR_L_W, EN_W,
|
||||||
|
STEP_R_A, DIR_R_A, STEP_L_A, DIR_L_A, EN_A,
|
||||||
|
STEP_SY, DIR_SY, EN_SERVO,
|
||||||
|
END_SY, END_RA, END_LA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Constants
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
CW = True
|
||||||
|
CCW = False
|
||||||
|
ON = 0 # A4988/TB6600: enable actiu en LOW
|
||||||
|
OFF = 1
|
||||||
|
TAKE = True
|
||||||
|
LEAVE = False
|
||||||
|
|
||||||
|
# Tipus de resposta del seguidor de línia
|
||||||
|
CLEAR = 0
|
||||||
|
CROSSING = 1
|
||||||
|
OBJECT = 2
|
||||||
|
|
||||||
|
# Noms de tasques (equivalent a les constants string del C++)
|
||||||
|
MOVE_TO_CROSSING = "Move to crossing"
|
||||||
|
TURN_90_CW = "Turn 90 CW"
|
||||||
|
TURN_90_CCW = "Turn 90 CCW"
|
||||||
|
MOVE_TO_OBJECT = "Move to object"
|
||||||
|
TAKE_SOMETHING = "Take something"
|
||||||
|
LEAVE_SOMETHING = "Leave something"
|
||||||
|
DO_NOTHING = "Do nothing"
|
||||||
|
|
||||||
|
# Paràmetres de moviment
|
||||||
|
WHEELS_MAX_SPEED = 130.0 # steps/s
|
||||||
|
WHEELS_ACCEL = 190.0 # steps/s²
|
||||||
|
ARMS_MAX_SPEED = 250.0 # steps/s
|
||||||
|
ARMS_ACCEL = 125.0 # steps/s²
|
||||||
|
SYRINGE_MAX_SPEED = 800.0 # steps/s
|
||||||
|
SYRINGE_ACCEL = 500.0 # steps/s²
|
||||||
|
|
||||||
|
WHEEL_MECH_REDUCTION = 5
|
||||||
|
WHEEL_STEPS_PER_REVOLUTION = 200 * WHEEL_MECH_REDUCTION # 1000 passos/volta de roda
|
||||||
|
|
||||||
|
MM_TO_CROSSING_CENTER = 62 # mm des del creuament detectat fins al centre
|
||||||
|
MM_TO_OBJECT = 20 # mm addicionals un cop detectat l'objecte
|
||||||
|
|
||||||
|
# Llindar de negre per ADS1115 (GAIN_ONE ±4.096V, single-ended 0–26400 per a 3.3V).
|
||||||
|
# Equivalent a 1500/4095 de l'ESP32 de 12 bits → ~9700 en ADS1115.
|
||||||
|
BLACK_THRESHOLD = 9700
|
||||||
|
|
||||||
|
# Llindar d'error de rotació (equivalent a 20/4095 de l'ESP32 → ~130 en ADS1115).
|
||||||
|
ROTATION_ERROR_THRESHOLD = 130
|
||||||
|
|
||||||
|
# Posicions dels braços en passos des del home
|
||||||
|
ARM_LOWER_POSITION = 120
|
||||||
|
ARM_L_UPPER_POSITION = 900
|
||||||
|
ARM_R_UPPER_POSITION = 550
|
||||||
|
|
||||||
|
# Xeringa: 10 rev * 200 passos/rev * microstepping x4 = 8000 passos estesa del tot
|
||||||
|
_SY_FULL_EXTENDED_STEPS = 10 * 200 * 4
|
||||||
|
|
||||||
|
LINE_FOLLOWER_FREQ = 100 # Hz
|
||||||
|
LINE_FOLLOWER_PERIOD = 1.0 / LINE_FOLLOWER_FREQ # s
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Classe Stepper
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
class Stepper:
|
||||||
|
"""
|
||||||
|
Motor pas a pas en mode DRIVER (STEP/DIR).
|
||||||
|
Equivalent a AccelStepper(DRIVER, step_pin, dir_pin).
|
||||||
|
Genera polsos STEP via pigpio.gpio_trigger().
|
||||||
|
"""
|
||||||
|
|
||||||
|
PULSE_US = 10 # Amplada del pols STEP en µs (A4988 requereix ≥1µs)
|
||||||
|
|
||||||
|
def __init__(self, pi: pigpio.pi, step_pin: int, dir_pin: int):
|
||||||
|
self._pi = pi
|
||||||
|
self._step_pin = step_pin
|
||||||
|
self._dir_pin = dir_pin
|
||||||
|
self._pos = 0 # posició actual (passos)
|
||||||
|
self._target = 0 # posició objectiu
|
||||||
|
self._speed = 0.0 # velocitat actual (passos/s, signada)
|
||||||
|
self._max_speed = 1.0
|
||||||
|
self._accel = 1.0
|
||||||
|
self._last_step_us = self._now_us()
|
||||||
|
self._step_interval_us = 0 # 0 = aturat
|
||||||
|
|
||||||
|
pi.set_mode(step_pin, pigpio.OUTPUT)
|
||||||
|
pi.set_mode(dir_pin, pigpio.OUTPUT)
|
||||||
|
pi.write(step_pin, 0)
|
||||||
|
pi.write(dir_pin, 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _now_us() -> int:
|
||||||
|
return time.monotonic_ns() // 1000
|
||||||
|
|
||||||
|
def set_max_speed(self, speed: float):
|
||||||
|
self._max_speed = abs(speed)
|
||||||
|
|
||||||
|
def set_acceleration(self, accel: float):
|
||||||
|
self._accel = abs(accel)
|
||||||
|
|
||||||
|
def move_to(self, position: int):
|
||||||
|
self._target = int(position)
|
||||||
|
|
||||||
|
def move(self, relative: int):
|
||||||
|
self._target = self._pos + int(relative)
|
||||||
|
|
||||||
|
def set_current_position(self, pos: int):
|
||||||
|
self._pos = int(pos)
|
||||||
|
self._target = int(pos)
|
||||||
|
self._speed = 0.0
|
||||||
|
self._step_interval_us = 0
|
||||||
|
|
||||||
|
def current_position(self) -> int:
|
||||||
|
return self._pos
|
||||||
|
|
||||||
|
def distance_to_go(self) -> int:
|
||||||
|
return self._target - self._pos
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._target != self._pos
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._target = self._pos
|
||||||
|
self._speed = 0.0
|
||||||
|
self._step_interval_us = 0
|
||||||
|
|
||||||
|
def set_speed(self, speed: float):
|
||||||
|
"""Estableix velocitat constant per a run_speed()."""
|
||||||
|
self._speed = float(speed)
|
||||||
|
self._step_interval_us = int(1_000_000 / abs(speed)) if speed != 0.0 else 0
|
||||||
|
|
||||||
|
def _do_step(self, direction: int):
|
||||||
|
self._pi.write(self._dir_pin, 1 if direction > 0 else 0)
|
||||||
|
self._pi.gpio_trigger(self._step_pin, self.PULSE_US, 1)
|
||||||
|
self._pos += direction
|
||||||
|
|
||||||
|
def run_speed(self) -> bool:
|
||||||
|
"""Fa un pas a velocitat constant. No bloquejant — cridar des del bucle de steppers."""
|
||||||
|
if self._step_interval_us == 0:
|
||||||
|
return False
|
||||||
|
now = self._now_us()
|
||||||
|
if now - self._last_step_us >= self._step_interval_us:
|
||||||
|
self._do_step(1 if self._speed > 0 else -1)
|
||||||
|
self._last_step_us = now
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def run(self) -> bool:
|
||||||
|
"""
|
||||||
|
Fa un pas cap a _target amb acceleració/desacceleració.
|
||||||
|
No bloquejant — cridar des del bucle de steppers.
|
||||||
|
Implementa l'algorisme de AccelStepper: Δv = accel / v per pas.
|
||||||
|
"""
|
||||||
|
dtg = self.distance_to_go()
|
||||||
|
if dtg == 0:
|
||||||
|
self._speed = 0.0
|
||||||
|
self._step_interval_us = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
abs_speed = abs(self._speed)
|
||||||
|
if abs_speed < 1.0:
|
||||||
|
abs_speed = math.sqrt(self._accel / 2.0) # velocitat inicial AccelStepper
|
||||||
|
|
||||||
|
now = self._now_us()
|
||||||
|
if now - self._last_step_us < int(1_000_000 / abs_speed):
|
||||||
|
return False # no és hora del proper pas
|
||||||
|
|
||||||
|
# Actualitza la velocitat per al proper pas
|
||||||
|
direction = 1 if dtg > 0 else -1
|
||||||
|
stop_dist = (abs_speed ** 2) / (2.0 * self._accel) if self._accel > 0 else 0
|
||||||
|
if abs(dtg) <= max(stop_dist, 1):
|
||||||
|
new_speed = abs_speed - (self._accel / abs_speed)
|
||||||
|
new_speed = max(new_speed, 1.0)
|
||||||
|
else:
|
||||||
|
new_speed = abs_speed + (self._accel / abs_speed)
|
||||||
|
new_speed = min(new_speed, self._max_speed)
|
||||||
|
|
||||||
|
self._speed = new_speed * direction
|
||||||
|
self._do_step(direction)
|
||||||
|
self._last_step_us = now
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Instàncies globals
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_pi: pigpio.pi = None
|
||||||
|
_i2c = None
|
||||||
|
_dist_sensor = None
|
||||||
|
_ads = None
|
||||||
|
_chan_r: AnalogIn = None
|
||||||
|
_chan_l: AnalogIn = None
|
||||||
|
|
||||||
|
wheel_R: Stepper = None
|
||||||
|
wheel_L: Stepper = None
|
||||||
|
arm_R: Stepper = None
|
||||||
|
arm_L: Stepper = None
|
||||||
|
syringe: Stepper = None
|
||||||
|
|
||||||
|
wheels_speed_mode: bool = False # True → run_speed(), False → run() (posició)
|
||||||
|
|
||||||
|
_stepper_stop = threading.Event()
|
||||||
|
_stepper_thread: threading.Thread = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Setup i cleanup
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def motion_setup_steppers(pi: pigpio.pi):
|
||||||
|
"""
|
||||||
|
Inicialitza GPIOs i steppers sense els sensors I2C.
|
||||||
|
Útil per a tests de motors quan VL53L0X/ADS1115 no estan connectats.
|
||||||
|
"""
|
||||||
|
global _pi, wheel_R, wheel_L, arm_R, arm_L, syringe, _stepper_thread
|
||||||
|
|
||||||
|
_pi = pi
|
||||||
|
|
||||||
|
for pin in (EN_W, EN_A, EN_SERVO):
|
||||||
|
pi.set_mode(pin, pigpio.OUTPUT)
|
||||||
|
for pin in (END_LA, END_RA, END_SY):
|
||||||
|
pi.set_mode(pin, pigpio.INPUT)
|
||||||
|
pi.set_pull_up_down(pin, pigpio.PUD_UP) # Hall actiu-baix, pull-up intern
|
||||||
|
|
||||||
|
enable_arms(OFF)
|
||||||
|
enable_wheels(OFF)
|
||||||
|
|
||||||
|
wheel_R = Stepper(pi, STEP_R_W, DIR_R_W)
|
||||||
|
wheel_L = Stepper(pi, STEP_L_W, DIR_L_W)
|
||||||
|
arm_R = Stepper(pi, STEP_R_A, DIR_R_A)
|
||||||
|
arm_L = Stepper(pi, STEP_L_A, DIR_L_A)
|
||||||
|
syringe = Stepper(pi, STEP_SY, DIR_SY)
|
||||||
|
|
||||||
|
wheel_R.set_max_speed(WHEELS_MAX_SPEED); wheel_R.set_acceleration(WHEELS_ACCEL)
|
||||||
|
wheel_L.set_max_speed(WHEELS_MAX_SPEED); wheel_L.set_acceleration(WHEELS_ACCEL)
|
||||||
|
arm_R.set_max_speed(ARMS_MAX_SPEED); arm_R.set_acceleration(ARMS_ACCEL)
|
||||||
|
arm_L.set_max_speed(ARMS_MAX_SPEED); arm_L.set_acceleration(ARMS_ACCEL)
|
||||||
|
syringe.set_max_speed(SYRINGE_MAX_SPEED); syringe.set_acceleration(SYRINGE_ACCEL)
|
||||||
|
|
||||||
|
_stepper_stop.clear()
|
||||||
|
_stepper_thread = threading.Thread(target=_stepper_loop, daemon=True, name="steppers")
|
||||||
|
_stepper_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def motion_setup_sensors(pi: pigpio.pi):
|
||||||
|
"""
|
||||||
|
Inicialitza únicament els sensors I2C (VL53L0X, ADS1115). Sense steppers.
|
||||||
|
Útil per a tests de sensors quan els motors no estan connectats.
|
||||||
|
"""
|
||||||
|
global _pi, _i2c, _dist_sensor, _ads, _chan_r, _chan_l
|
||||||
|
|
||||||
|
_pi = pi
|
||||||
|
|
||||||
|
_i2c = adafruit_extended_bus.ExtendedI2C(3)
|
||||||
|
_dist_sensor = adafruit_vl53l0x.VL53L0X(_i2c)
|
||||||
|
_ads = ADS.ADS1115(_i2c)
|
||||||
|
_chan_r = AnalogIn(_ads, ADS.P0)
|
||||||
|
_chan_l = AnalogIn(_ads, ADS.P1)
|
||||||
|
|
||||||
|
|
||||||
|
def motion_setup(pi: pigpio.pi):
|
||||||
|
"""
|
||||||
|
Inicialitza GPIOs, steppers, sensor de distància VL53L0X,
|
||||||
|
ADC ADS1115 per als sensors de línia, i arrenca el bucle de steppers.
|
||||||
|
Requereix /boot/config.txt: dtoverlay=i2c-gpio,bus=3,i2c_gpio_sda=2,i2c_gpio_scl=1
|
||||||
|
"""
|
||||||
|
motion_setup_steppers(pi)
|
||||||
|
motion_setup_sensors(pi)
|
||||||
|
|
||||||
|
|
||||||
|
def motion_cleanup():
|
||||||
|
"""Atura el bucle de steppers i desactiva tots els motors."""
|
||||||
|
_stepper_stop.set()
|
||||||
|
if _stepper_thread:
|
||||||
|
_stepper_thread.join(timeout=1.0)
|
||||||
|
enable_wheels(OFF)
|
||||||
|
enable_arms(OFF)
|
||||||
|
enable_syringe(OFF)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Enable / disable
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def enable_wheels(state: bool):
|
||||||
|
_pi.write(EN_W, state)
|
||||||
|
|
||||||
|
def enable_arms(state: bool):
|
||||||
|
_pi.write(EN_A, state)
|
||||||
|
|
||||||
|
def enable_syringe(state: bool):
|
||||||
|
_pi.write(EN_SERVO, state)
|
||||||
|
|
||||||
|
def is_endstop_detecting(pin: int) -> bool:
|
||||||
|
return not _pi.read(pin) # efecte Hall actiu en baix
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Helpers de moviment
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def mm_to_steps(mm: int) -> int:
|
||||||
|
# Perímetre roda Ø152mm = 2·π·76 ≈ 477mm
|
||||||
|
return (mm * WHEEL_STEPS_PER_REVOLUTION * 2) // 1000
|
||||||
|
|
||||||
|
def wheels_set_position(position: int = 0):
|
||||||
|
wheel_L.set_current_position(position)
|
||||||
|
wheel_R.set_current_position(position)
|
||||||
|
|
||||||
|
def wheels_set_speed(speed: float, rotate: bool = False, direction: bool = CW):
|
||||||
|
if rotate:
|
||||||
|
wheel_L.set_speed( speed if direction == CW else -speed)
|
||||||
|
wheel_R.set_speed(-speed if direction == CW else speed)
|
||||||
|
else:
|
||||||
|
wheel_L.set_speed(speed)
|
||||||
|
wheel_R.set_speed(speed)
|
||||||
|
|
||||||
|
def move_arms_to(position: int):
|
||||||
|
arm_L.move_to(position)
|
||||||
|
arm_R.move_to(position)
|
||||||
|
while arm_L.distance_to_go() != 0 and arm_R.distance_to_go() != 0:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def move_arms_up():
|
||||||
|
arm_L.move_to(ARM_L_UPPER_POSITION)
|
||||||
|
arm_R.move_to(ARM_R_UPPER_POSITION)
|
||||||
|
while arm_L.distance_to_go() != 0 and arm_R.distance_to_go() != 0:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def move_wheels_to(position: int, invert: bool = False):
|
||||||
|
wheel_L.move_to(position)
|
||||||
|
wheel_R.move_to(-position if invert else position)
|
||||||
|
while wheel_L.is_running() and wheel_R.is_running():
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Homing
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def arms_home():
|
||||||
|
"""Cicle de homing dels braços (bloquejant). El bucle de steppers fa el moviment."""
|
||||||
|
enable_arms(ON)
|
||||||
|
arm_L.move(-1250)
|
||||||
|
arm_R.move(-1250)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if is_endstop_detecting(END_LA):
|
||||||
|
arm_L.stop()
|
||||||
|
if is_endstop_detecting(END_RA):
|
||||||
|
arm_R.stop()
|
||||||
|
l_done = is_endstop_detecting(END_LA) or arm_L.distance_to_go() == 0
|
||||||
|
r_done = is_endstop_detecting(END_RA) or arm_R.distance_to_go() == 0
|
||||||
|
if l_done and r_done:
|
||||||
|
break
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
|
arm_L.set_current_position(0)
|
||||||
|
arm_R.set_current_position(0)
|
||||||
|
arm_L.move(ARM_L_UPPER_POSITION)
|
||||||
|
arm_R.move(ARM_R_UPPER_POSITION)
|
||||||
|
|
||||||
|
while arm_L.distance_to_go() != 0 or arm_R.distance_to_go() != 0:
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def syringe_home():
|
||||||
|
"""Cicle de homing de la xeringa (bloquejant). El bucle de steppers fa el moviment."""
|
||||||
|
enable_syringe(ON)
|
||||||
|
syringe.move(-11000)
|
||||||
|
|
||||||
|
while not is_endstop_detecting(END_SY):
|
||||||
|
if syringe.distance_to_go() == 0:
|
||||||
|
break
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
|
syringe.stop()
|
||||||
|
syringe.set_current_position(0)
|
||||||
|
enable_syringe(OFF)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Sensor de distància
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def distance_to_object() -> int:
|
||||||
|
"""Retorna la distància en mm a l'objecte més proper. 65535 si fora de rang."""
|
||||||
|
try:
|
||||||
|
return _dist_sensor.range
|
||||||
|
except Exception:
|
||||||
|
return 65535
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Seguidor de línia
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _read_line_sensors() -> tuple:
|
||||||
|
"""Llegeix sensors de línia via ADS1115. Retorna (dreta, esquerra), 0–32767."""
|
||||||
|
return _chan_r.value, _chan_l.value
|
||||||
|
|
||||||
|
def compute_new_speed(speed: float) -> float:
|
||||||
|
accel = WHEELS_ACCEL / LINE_FOLLOWER_FREQ
|
||||||
|
return min(speed + accel, WHEELS_MAX_SPEED)
|
||||||
|
|
||||||
|
def follow_line_loop(speed: float, forward: bool = True) -> int:
|
||||||
|
"""
|
||||||
|
Seguidor de línia no bloquejant. Retorna CLEAR, CROSSING o OBJECT.
|
||||||
|
Si CLEAR, aplica correcció proporcional a les velocitats de les rodes.
|
||||||
|
"""
|
||||||
|
distance_threshold = 50 # mm
|
||||||
|
|
||||||
|
lf_r, lf_l = _read_line_sensors()
|
||||||
|
|
||||||
|
if lf_l > BLACK_THRESHOLD and lf_r > BLACK_THRESHOLD and forward:
|
||||||
|
return CROSSING
|
||||||
|
elif distance_to_object() < distance_threshold and forward:
|
||||||
|
return OBJECT
|
||||||
|
else:
|
||||||
|
p_factor = 5
|
||||||
|
error = lf_r - lf_l
|
||||||
|
correction_r = error // p_factor
|
||||||
|
correction_l = -correction_r
|
||||||
|
|
||||||
|
wheel_L.set_speed((speed + correction_r) if forward else (-speed + correction_r))
|
||||||
|
wheel_R.set_speed((speed + correction_l) if forward else (-speed + correction_l))
|
||||||
|
return CLEAR
|
||||||
|
|
||||||
|
def run_to_crossing_center(speed: float) -> float:
|
||||||
|
"""
|
||||||
|
Avança per centrar el robot sobre el creuament detectat.
|
||||||
|
Retorna la nova velocitat (reduïda a la meitat).
|
||||||
|
"""
|
||||||
|
wheels_set_position(0 - mm_to_steps(MM_TO_CROSSING_CENTER))
|
||||||
|
|
||||||
|
while follow_line_loop(speed) == CROSSING:
|
||||||
|
wheels_set_speed(speed)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
speed /= 2
|
||||||
|
while wheel_L.current_position() < 0 and wheel_R.current_position() < 0:
|
||||||
|
wheels_set_speed(speed)
|
||||||
|
time.sleep(0.1)
|
||||||
|
wheels_set_speed(0)
|
||||||
|
return speed
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Bucle de steppers (thread permanent)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _stepper_loop():
|
||||||
|
"""
|
||||||
|
Thread que genera els polsos STEP per a tots els motors.
|
||||||
|
Equivalent a task_update_steppers() del FreeRTOS.
|
||||||
|
S'inicia automàticament a motion_setup().
|
||||||
|
"""
|
||||||
|
while not _stepper_stop.is_set():
|
||||||
|
if wheels_speed_mode:
|
||||||
|
wheel_R.run_speed()
|
||||||
|
wheel_L.run_speed()
|
||||||
|
else:
|
||||||
|
wheel_R.run()
|
||||||
|
wheel_L.run()
|
||||||
|
arm_R.run()
|
||||||
|
arm_L.run()
|
||||||
|
syringe.run()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Tasques (FreeRTOS tasks → funcions bloquejants, cridades des de threads)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def task_move_to(expected_target: int):
|
||||||
|
"""
|
||||||
|
Segueix la línia fins arribar a expected_target (CROSSING o OBJECT).
|
||||||
|
Equivalent a task_move_to() del FreeRTOS.
|
||||||
|
"""
|
||||||
|
global wheels_speed_mode
|
||||||
|
|
||||||
|
speed = 0.0
|
||||||
|
wheels_speed_mode = True
|
||||||
|
enable_wheels(ON)
|
||||||
|
|
||||||
|
lf_response = CLEAR
|
||||||
|
while True:
|
||||||
|
speed = compute_new_speed(speed)
|
||||||
|
lf_response = follow_line_loop(speed)
|
||||||
|
if lf_response != CLEAR:
|
||||||
|
break
|
||||||
|
time.sleep(LINE_FOLLOWER_PERIOD)
|
||||||
|
|
||||||
|
if lf_response == CROSSING and expected_target == CROSSING:
|
||||||
|
run_to_crossing_center(speed)
|
||||||
|
elif lf_response == OBJECT and expected_target == OBJECT:
|
||||||
|
wheel_L.stop()
|
||||||
|
wheel_R.stop()
|
||||||
|
|
||||||
|
wheels_speed_mode = False
|
||||||
|
enable_wheels(OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def task_rotate(direction: bool):
|
||||||
|
"""
|
||||||
|
Gira el robot 90° (CW o CCW).
|
||||||
|
Fase 1: acceleració fins al 90% dels passos de rotació.
|
||||||
|
Fase 2: desacceleració i ajust fi sobre la línia via sensors analògics.
|
||||||
|
"""
|
||||||
|
global wheels_speed_mode
|
||||||
|
|
||||||
|
# Arc 90° a Ø250mm → 2·π·125/4 ≈ 196mm; roda Ø152mm → 196/477 ≈ 0.41 voltes
|
||||||
|
ROTATION_STEPS = (WHEEL_STEPS_PER_REVOLUTION * 42) // 100
|
||||||
|
positive_wheel = wheel_L if direction == CW else wheel_R
|
||||||
|
|
||||||
|
speed = 0.0
|
||||||
|
wheels_speed_mode = True
|
||||||
|
enable_wheels(ON)
|
||||||
|
wheels_set_position(0)
|
||||||
|
|
||||||
|
while positive_wheel.current_position() < (ROTATION_STEPS * 90) // 100:
|
||||||
|
speed = compute_new_speed(speed)
|
||||||
|
wheels_set_speed(speed, rotate=True, direction=direction)
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
speed /= 2
|
||||||
|
while True:
|
||||||
|
lf_r, lf_l = _read_line_sensors()
|
||||||
|
error = abs(lf_l - lf_r)
|
||||||
|
if error < ROTATION_ERROR_THRESHOLD or \
|
||||||
|
positive_wheel.current_position() > (ROTATION_STEPS * 110) // 100:
|
||||||
|
break
|
||||||
|
wheels_set_speed(speed - (speed / error), rotate=True, direction=direction)
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
wheels_set_speed(0)
|
||||||
|
enable_wheels(OFF)
|
||||||
|
wheels_speed_mode = False
|
||||||
|
|
||||||
|
|
||||||
|
def task_take_or_leave_something(take: bool):
|
||||||
|
"""
|
||||||
|
Avança fins a l'objecte, baixa braços, opera la xeringa i torna al creuament.
|
||||||
|
take=True → xucla; take=False → buida.
|
||||||
|
"""
|
||||||
|
global wheels_speed_mode
|
||||||
|
|
||||||
|
speed = 0.0
|
||||||
|
wheels_speed_mode = True
|
||||||
|
enable_wheels(ON)
|
||||||
|
wheels_set_position(0) # guardem la posició home per tornar-hi
|
||||||
|
|
||||||
|
lf_response = CLEAR
|
||||||
|
while True:
|
||||||
|
speed = compute_new_speed(speed)
|
||||||
|
lf_response = follow_line_loop(speed)
|
||||||
|
if lf_response != CLEAR:
|
||||||
|
break
|
||||||
|
time.sleep(LINE_FOLLOWER_PERIOD)
|
||||||
|
|
||||||
|
if lf_response == OBJECT:
|
||||||
|
target_l = wheel_L.current_position() + mm_to_steps(MM_TO_OBJECT)
|
||||||
|
target_r = wheel_R.current_position() + mm_to_steps(MM_TO_OBJECT)
|
||||||
|
while wheel_L.current_position() < target_l and wheel_R.current_position() < target_r:
|
||||||
|
wheels_set_speed(speed / 2)
|
||||||
|
time.sleep(0.1)
|
||||||
|
wheels_set_speed(0)
|
||||||
|
enable_wheels(OFF)
|
||||||
|
|
||||||
|
enable_arms(ON)
|
||||||
|
move_arms_to(ARM_LOWER_POSITION)
|
||||||
|
|
||||||
|
enable_syringe(ON)
|
||||||
|
syringe.move_to(_SY_FULL_EXTENDED_STEPS if take else 0)
|
||||||
|
while syringe.distance_to_go() != 0:
|
||||||
|
if not take and is_endstop_detecting(END_SY):
|
||||||
|
syringe.stop()
|
||||||
|
syringe.set_current_position(0)
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
enable_syringe(OFF)
|
||||||
|
|
||||||
|
move_arms_up()
|
||||||
|
|
||||||
|
enable_wheels(ON)
|
||||||
|
wheels_speed_mode = False
|
||||||
|
wheels_set_speed(WHEELS_MAX_SPEED)
|
||||||
|
move_wheels_to(0)
|
||||||
|
|
||||||
|
elif lf_response == CROSSING:
|
||||||
|
run_to_crossing_center(speed)
|
||||||
|
|
||||||
|
wheels_speed_mode = False
|
||||||
|
enable_wheels(OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def task_idle():
|
||||||
|
"""Pausa breu fins que quibot.py assigni una nova tasca."""
|
||||||
|
time.sleep(0.5)
|
||||||
84
raspi/pins.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
pins.py — Definició de pins GPIO (BCM) de la Raspberry Pi Zero 2W.
|
||||||
|
Equivalent a io.h del codi Arduino/ESP32.
|
||||||
|
Tots els números fan referència a la numeració BCM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# MOTORS
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
# Servo d'expulsió de blocs
|
||||||
|
EN_SERVO = 9
|
||||||
|
SERVO_PWM = 10
|
||||||
|
|
||||||
|
# Motor pas a pas xeringa
|
||||||
|
STEP_SY = 8
|
||||||
|
DIR_SY = 5
|
||||||
|
|
||||||
|
# Motor pas a pas roda dreta
|
||||||
|
STEP_R_W = 25
|
||||||
|
DIR_R_W = 23
|
||||||
|
|
||||||
|
# Motor pas a pas roda esquerra
|
||||||
|
STEP_L_W = 7
|
||||||
|
DIR_L_W = 11
|
||||||
|
|
||||||
|
# Enable motors de rodes (compartit)
|
||||||
|
EN_W = 6
|
||||||
|
|
||||||
|
# Motor pas a pas braç dret
|
||||||
|
STEP_R_A = 3
|
||||||
|
DIR_R_A = 4
|
||||||
|
|
||||||
|
# Motor pas a pas braç esquerre
|
||||||
|
STEP_L_A = 13
|
||||||
|
DIR_L_A = 0
|
||||||
|
|
||||||
|
# Enable motors de braços (compartit)
|
||||||
|
EN_A = 21
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# SENSORS
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
# Bus I2C principal — VL53L0X (distància) i PAJ7620U2 (gestos), compartit
|
||||||
|
SDA_DIST = 2
|
||||||
|
SCL_DIST = 1
|
||||||
|
SDA_GEST = SDA_DIST # mateixa línia
|
||||||
|
SCL_GEST = SCL_DIST # mateixa línia
|
||||||
|
# INT_GEST no connectat a la PCB — el driver usa polling
|
||||||
|
|
||||||
|
# Bus I2C sensor de color TCS34725 (bit-bang)
|
||||||
|
SDA_COL = 22
|
||||||
|
SCL_COL = 27
|
||||||
|
|
||||||
|
# Final de carrera xeringa (efecte Hall)
|
||||||
|
END_SY = 12
|
||||||
|
|
||||||
|
# Final de carrera braç dret (efecte Hall)
|
||||||
|
END_RA = 16
|
||||||
|
|
||||||
|
# Final de carrera braç esquerre (efecte Hall)
|
||||||
|
END_LA = 17
|
||||||
|
|
||||||
|
# Sensors seguidors de línia (TCRT5000)
|
||||||
|
LINES_R = 14
|
||||||
|
LINES_L = 15
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# DISPLAY
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
# Dades matriu LED 8x8 RGB WS2811 (2x ull)
|
||||||
|
LED_DATA = 26
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# ÀUDIO (afegit per company, no usat pel robot)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
# I2S — amplificador MAX98357A + micròfon SPH0645
|
||||||
|
I2C_BCLK = 18
|
||||||
|
I2C_LRCLK = 19
|
||||||
|
AMP_DIN = 24
|
||||||
|
MIC = 20
|
||||||
286
raspi/quibot.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
quibot.py — Programa principal del robot QuiBot H2O.
|
||||||
|
Inicialitza tots els mòduls, executa el homing i arrenca els threads.
|
||||||
|
Equivalent a QuiBot.ino del codi Arduino/ESP32.
|
||||||
|
|
||||||
|
Threads permanents:
|
||||||
|
- task_read_blocks → llegeix blocs i executa accions (aquest fitxer)
|
||||||
|
- task_read_gestures → llegeix gestos i executa accions (aquest fitxer)
|
||||||
|
- _stepper_loop → genera polsos STEP dels motors (motion.py)
|
||||||
|
- _task_update_leds → parpelleig dels LEDs (eyes.py)
|
||||||
|
- _poll_loop → polling del sensor de gestos (gesture.py)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import pigpio
|
||||||
|
|
||||||
|
from motion import (
|
||||||
|
motion_setup, motion_cleanup,
|
||||||
|
arms_home, syringe_home,
|
||||||
|
task_move_to, task_rotate, task_take_or_leave_something, task_idle,
|
||||||
|
distance_to_object,
|
||||||
|
CROSSING, TAKE, LEAVE, CW, CCW,
|
||||||
|
)
|
||||||
|
from blocks import (
|
||||||
|
blocks_setup,
|
||||||
|
read_block_color, servo_move_to,
|
||||||
|
OPEN_POSITION, EJECT_POSITION,
|
||||||
|
BK, RD, GN, BU, YE, OG, VT,
|
||||||
|
)
|
||||||
|
from eyes import (
|
||||||
|
eyes_setup, eyes_cleanup,
|
||||||
|
eyes_turn_on, eyes_turn_off,
|
||||||
|
eyes_gesture_mode_on, eyes_gesture_mode_off,
|
||||||
|
EYES_OPEN, EYES_FW, EYES_DOWN,
|
||||||
|
RED, GREEN, BLUE, YELLOW, ORANGE, CYAN,
|
||||||
|
)
|
||||||
|
from gesture import (
|
||||||
|
gesture_setup, gesture_cleanup,
|
||||||
|
read_gesture,
|
||||||
|
GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT,
|
||||||
|
GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Colors addicionals (equivalents CRGB del C++)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
GRAY = (128, 128, 128) # CRGB::Gray → estat normal
|
||||||
|
DARK_RED = (139, 0, 0) # CRGB::DarkRed → avançar
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Timeouts
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
INSERT_BLOCK_MS = 2.0 # s — espera que l'infant insereixi el bloc
|
||||||
|
EJECT_BLOCK_MS = 2.0 # s — espera que el bloc caigui
|
||||||
|
CHECK_COLOR_MS = 1.0 # s — interval entre lectures de color
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Estat global
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
_pi: pigpio.pi = None
|
||||||
|
|
||||||
|
# Mutex que evita que tasca de blocs i tasca de gestos llancin accions simultànies.
|
||||||
|
# En el C++ original compartien TaskHandle sense mutex explícit (possible race condition).
|
||||||
|
# Aquí ho fem correctament.
|
||||||
|
_action_lock = threading.Lock()
|
||||||
|
|
||||||
|
_shutdown_event = threading.Event()
|
||||||
|
_gesture_mode_active = False # False = mode blocs, True = mode gestos
|
||||||
|
_mode_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Helper d'execució d'accions
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _execute_action(fn, *args):
|
||||||
|
"""
|
||||||
|
Adquireix el lock d'acció, executa fn(*args) de forma bloquejant i l'allibera.
|
||||||
|
Garanteix que mai s'executen dues accions de moviment simultànies.
|
||||||
|
"""
|
||||||
|
with _action_lock:
|
||||||
|
fn(*args)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Tasca de blocs
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def task_read_blocks():
|
||||||
|
"""
|
||||||
|
Llegeix blocs contínuament, executa l'acció corresponent al color i expulsa el bloc.
|
||||||
|
Equivalent a task_read_blocks() del FreeRTOS.
|
||||||
|
"""
|
||||||
|
while not _shutdown_event.is_set():
|
||||||
|
eyes_state = False
|
||||||
|
eyes_turn_on(EYES_OPEN, GRAY)
|
||||||
|
|
||||||
|
# Obre el servo per permetre la inserció del bloc
|
||||||
|
servo_move_to(OPEN_POSITION)
|
||||||
|
time.sleep(INSERT_BLOCK_MS)
|
||||||
|
|
||||||
|
# Espera que hi hagi un bloc i llegeix el seu color
|
||||||
|
color_id = BK
|
||||||
|
while not _shutdown_event.is_set():
|
||||||
|
if distance_to_object() < 80:
|
||||||
|
# Objecte a menys de 80mm — esperem que es retiri
|
||||||
|
if not eyes_state:
|
||||||
|
eyes_turn_on(EYES_DOWN, GRAY)
|
||||||
|
eyes_state = True
|
||||||
|
else:
|
||||||
|
color_id = read_block_color()
|
||||||
|
if eyes_state:
|
||||||
|
eyes_turn_on(EYES_OPEN, GRAY, 1, False)
|
||||||
|
eyes_state = False
|
||||||
|
if color_id != BK:
|
||||||
|
break
|
||||||
|
time.sleep(CHECK_COLOR_MS)
|
||||||
|
|
||||||
|
# Si estem en mode gestos, ignora el bloc
|
||||||
|
with _mode_lock:
|
||||||
|
if _gesture_mode_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Executa l'acció corresponent al color del bloc
|
||||||
|
if color_id == RD:
|
||||||
|
eyes_turn_on(EYES_FW, DARK_RED, 2)
|
||||||
|
time.sleep(1.0)
|
||||||
|
_execute_action(task_move_to, CROSSING)
|
||||||
|
|
||||||
|
elif color_id == GN:
|
||||||
|
eyes_turn_on(EYES_OPEN, GREEN, 2)
|
||||||
|
time.sleep(1.0)
|
||||||
|
_execute_action(task_rotate, CW)
|
||||||
|
|
||||||
|
elif color_id == BU:
|
||||||
|
eyes_turn_on(EYES_OPEN, BLUE, 2)
|
||||||
|
time.sleep(1.0)
|
||||||
|
_execute_action(task_rotate, CCW)
|
||||||
|
|
||||||
|
elif color_id == YE:
|
||||||
|
eyes_turn_on(EYES_OPEN, YELLOW, 2)
|
||||||
|
time.sleep(1.0)
|
||||||
|
_execute_action(task_take_or_leave_something, TAKE)
|
||||||
|
|
||||||
|
elif color_id == OG:
|
||||||
|
eyes_turn_on(EYES_OPEN, ORANGE, 2)
|
||||||
|
time.sleep(1.0)
|
||||||
|
_execute_action(task_take_or_leave_something, LEAVE)
|
||||||
|
|
||||||
|
elif color_id == VT:
|
||||||
|
_execute_action(task_idle)
|
||||||
|
|
||||||
|
eyes_turn_on(EYES_OPEN, GRAY)
|
||||||
|
|
||||||
|
# Expulsa el bloc
|
||||||
|
servo_move_to(EJECT_POSITION)
|
||||||
|
time.sleep(EJECT_BLOCK_MS)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Tasca de gestos
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def task_read_gestures():
|
||||||
|
"""
|
||||||
|
Llegeix gestos contínuament.
|
||||||
|
GS_WAVE activa/desactiva el mode gestos.
|
||||||
|
Equivalent a task_read_gestures() del FreeRTOS.
|
||||||
|
"""
|
||||||
|
gesture_mode_active = False
|
||||||
|
|
||||||
|
while not _shutdown_event.is_set():
|
||||||
|
gesture_id = read_gesture()
|
||||||
|
|
||||||
|
# WAVE: toggle entre mode blocs i mode gestos
|
||||||
|
if gesture_id == GS_WAVE:
|
||||||
|
with _mode_lock:
|
||||||
|
_gesture_mode_active = not _gesture_mode_active
|
||||||
|
active = _gesture_mode_active
|
||||||
|
if active:
|
||||||
|
eyes_gesture_mode_on()
|
||||||
|
print("Gesture mode ON")
|
||||||
|
else:
|
||||||
|
eyes_gesture_mode_off()
|
||||||
|
print("Gesture mode OFF")
|
||||||
|
time.sleep(1.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with _mode_lock:
|
||||||
|
active = _gesture_mode_active
|
||||||
|
if not active or gesture_id == GS_NONE:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Executa l'acció corresponent al gest
|
||||||
|
if gesture_id == GS_FORWARD:
|
||||||
|
eyes_turn_on(EYES_FW, DARK_RED, 2)
|
||||||
|
_execute_action(task_move_to, CROSSING)
|
||||||
|
|
||||||
|
elif gesture_id == GS_RIGHT:
|
||||||
|
eyes_turn_on(EYES_OPEN, GREEN, 2)
|
||||||
|
_execute_action(task_rotate, CW)
|
||||||
|
|
||||||
|
elif gesture_id == GS_LEFT:
|
||||||
|
eyes_turn_on(EYES_OPEN, BLUE, 2)
|
||||||
|
_execute_action(task_rotate, CCW)
|
||||||
|
|
||||||
|
elif gesture_id == GS_UP:
|
||||||
|
eyes_turn_on(EYES_OPEN, YELLOW, 2)
|
||||||
|
_execute_action(task_take_or_leave_something, TAKE)
|
||||||
|
|
||||||
|
elif gesture_id == GS_DOWN:
|
||||||
|
eyes_turn_on(EYES_OPEN, ORANGE, 2)
|
||||||
|
_execute_action(task_take_or_leave_something, LEAVE)
|
||||||
|
|
||||||
|
elif gesture_id in (GS_CLOCKWISE, GS_ANTICLOCKWISE):
|
||||||
|
_execute_action(task_idle)
|
||||||
|
|
||||||
|
eyes_turn_on(EYES_OPEN, CYAN)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Shutdown
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def _shutdown(sig, frame):
|
||||||
|
print("\nAturant QuiBot...")
|
||||||
|
_shutdown_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Main
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global _pi
|
||||||
|
|
||||||
|
# Connecta amb pigpiod (ha d'estar en marxa amb: sudo pigpiod -s 1)
|
||||||
|
_pi = pigpio.pi()
|
||||||
|
if not _pi.connected:
|
||||||
|
print("ERROR: No s'ha pogut connectar a pigpiod. Executa: sudo pigpiod -s 1")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Inicialitza tots els mòduls
|
||||||
|
blocks_setup(_pi)
|
||||||
|
motion_setup(_pi)
|
||||||
|
eyes_setup(_pi)
|
||||||
|
gesture_setup()
|
||||||
|
|
||||||
|
# Homing (bloquejant)
|
||||||
|
arms_home()
|
||||||
|
syringe_home()
|
||||||
|
|
||||||
|
# Registra els senyals de sortida
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
|
||||||
|
# Arrenca els threads principals
|
||||||
|
t_blocks = threading.Thread(target=task_read_blocks, daemon=True, name="blocks")
|
||||||
|
t_gestures = threading.Thread(target=task_read_gestures, daemon=True, name="gestures")
|
||||||
|
|
||||||
|
t_blocks.start()
|
||||||
|
t_gestures.start()
|
||||||
|
|
||||||
|
print("QuiBot llest.")
|
||||||
|
|
||||||
|
# Espera senyal de sortida
|
||||||
|
_shutdown_event.wait()
|
||||||
|
|
||||||
|
# Cleanup ordenat
|
||||||
|
motion_cleanup()
|
||||||
|
eyes_cleanup()
|
||||||
|
gesture_cleanup()
|
||||||
|
_pi.stop()
|
||||||
|
print("QuiBot aturat.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
147
raspi/tests/test_blocks.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
test_blocks.py — Tests individuals del mòdul blocks.py.
|
||||||
|
Executa des del directori Rasp/: python tests/test_blocks.py
|
||||||
|
|
||||||
|
Descomenta la funció que vols provar al final del fitxer.
|
||||||
|
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pigpio
|
||||||
|
from blocks import (
|
||||||
|
blocks_setup,
|
||||||
|
servo_move_to, OPEN_POSITION, EJECT_POSITION, MIN_SERVO_US, MAX_SERVO_US,
|
||||||
|
read_block_color, read_color_raw,
|
||||||
|
_COLORS,
|
||||||
|
BK, RD, GN, BU, YE, OG, VT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_COLOR_NAMES = {
|
||||||
|
BK: "Negre (BK)",
|
||||||
|
RD: "Vermell (RD)",
|
||||||
|
GN: "Verd (GN)",
|
||||||
|
BU: "Blau (BU)",
|
||||||
|
YE: "Groc (YE)",
|
||||||
|
OG: "Taronja (OG)",
|
||||||
|
VT: "Violeta (VT)",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _setup():
|
||||||
|
pi = pigpio.pi()
|
||||||
|
if not pi.connected:
|
||||||
|
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
|
||||||
|
sys.exit(1)
|
||||||
|
blocks_setup(pi)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _teardown(pi):
|
||||||
|
pi.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 1 — Servo
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_servo():
|
||||||
|
"""
|
||||||
|
Mou el servo a les posicions principals: oberta, expulsió i torna a oberta.
|
||||||
|
Hauries de veure/sentir el servo moure's suaument entre posicions.
|
||||||
|
"""
|
||||||
|
print("=== TEST SERVO ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print(f"Movent a OPEN_POSITION ({OPEN_POSITION} µs)...")
|
||||||
|
servo_move_to(OPEN_POSITION)
|
||||||
|
time.sleep(1.0)
|
||||||
|
print("Open: OK")
|
||||||
|
|
||||||
|
print(f"Movent a EJECT_POSITION ({EJECT_POSITION} µs)...")
|
||||||
|
servo_move_to(EJECT_POSITION)
|
||||||
|
time.sleep(1.0)
|
||||||
|
print("Eject: OK")
|
||||||
|
|
||||||
|
print(f"Tornant a OPEN_POSITION...")
|
||||||
|
servo_move_to(OPEN_POSITION)
|
||||||
|
time.sleep(1.0)
|
||||||
|
print("Torna a open: OK")
|
||||||
|
|
||||||
|
print(f"Provant posició mínima ({MIN_SERVO_US} µs)...")
|
||||||
|
servo_move_to(MIN_SERVO_US)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
print(f"Tornant a OPEN_POSITION...")
|
||||||
|
servo_move_to(OPEN_POSITION)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test servo completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 2 — Sensor de color TCS34725
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_color_sensor():
|
||||||
|
"""
|
||||||
|
Llegeix el color 15 vegades cada segon.
|
||||||
|
Posa davant del sensor els blocs de colors per verificar que els reconeix.
|
||||||
|
"""
|
||||||
|
print("=== TEST SENSOR DE COLOR TCS34725 ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print("Llegint color durant 15 segons (posa els blocs davant del sensor)...")
|
||||||
|
for i in range(15):
|
||||||
|
color_id = read_block_color()
|
||||||
|
name = _COLOR_NAMES.get(color_id, "Desconegut")
|
||||||
|
print(f" Lectura {i+1:2d}: {name}")
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test sensor de color completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 3 — Calibració del sensor de color (valors RGB crus)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_color_raw():
|
||||||
|
"""
|
||||||
|
Mostra valors RGB crus i la classificació actual durant 30 segons.
|
||||||
|
Útil per calibrar la taula _COLORS a blocks.py.
|
||||||
|
Format: R=xxx G=xxx B=xxx → classificat com 'XX' (diff=xx)
|
||||||
|
Si el diff és gran (>10), els valors de referència necessiten ajust.
|
||||||
|
"""
|
||||||
|
print("=== TEST COLOR RAW (calibració) ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print("Llegint valors RGB crus durant 30 segons (posa cada bloc davant del sensor)...")
|
||||||
|
print(f" {'R':>5} {'G':>5} {'B':>5} classificat diff")
|
||||||
|
for i in range(30):
|
||||||
|
r, g, b = read_color_raw()
|
||||||
|
best_name = "??"
|
||||||
|
best_diff = 9999
|
||||||
|
for ref in _COLORS:
|
||||||
|
diff = abs(r - ref["r"]) + abs(g - ref["g"]) + abs(b - ref["b"])
|
||||||
|
if diff < best_diff:
|
||||||
|
best_diff = diff
|
||||||
|
best_name = ref["name"]
|
||||||
|
print(f" R={r:3d} G={g:3d} B={b:3d} → {best_name} (diff={best_diff})")
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test color raw completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Execució
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Descomenta el test que vols executar:
|
||||||
|
test_servo()
|
||||||
|
# test_color_sensor()
|
||||||
|
# test_color_raw() # Per calibrar la taula de colors
|
||||||
156
raspi/tests/test_eyes.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
test_eyes.py — Tests individuals del mòdul eyes.py.
|
||||||
|
Executa des del directori Rasp/: python tests/test_eyes.py
|
||||||
|
|
||||||
|
Descomenta la funció que vols provar al final del fitxer.
|
||||||
|
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pigpio
|
||||||
|
from eyes import (
|
||||||
|
eyes_setup, eyes_cleanup,
|
||||||
|
eyes_turn_on, eyes_turn_off,
|
||||||
|
eyes_gesture_mode_on, eyes_gesture_mode_off, eyes_listening,
|
||||||
|
EYES_OPEN, EYES_FW, EYES_DOWN, EYES_GESTURE,
|
||||||
|
WHITE, RED, GREEN, BLUE, YELLOW, ORANGE, PURPLE, CYAN, BLACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _setup():
|
||||||
|
pi = pigpio.pi()
|
||||||
|
if not pi.connected:
|
||||||
|
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
|
||||||
|
sys.exit(1)
|
||||||
|
eyes_setup(pi)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _teardown(pi):
|
||||||
|
eyes_cleanup()
|
||||||
|
pi.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 1 — Formes i colors bàsics
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_shapes():
|
||||||
|
"""
|
||||||
|
Mostra totes les formes existents en colors diferents.
|
||||||
|
Hauries de veure les formes als ulls LED del robot.
|
||||||
|
"""
|
||||||
|
print("=== TEST FORMES I COLORS ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print("EYES_OPEN en blanc...")
|
||||||
|
eyes_turn_on(EYES_OPEN, WHITE)
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
print("EYES_FW en vermell...")
|
||||||
|
eyes_turn_on(EYES_FW, RED)
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
print("EYES_DOWN en blau...")
|
||||||
|
eyes_turn_on(EYES_DOWN, BLUE)
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
print("EYES_OPEN en verd...")
|
||||||
|
eyes_turn_on(EYES_OPEN, GREEN)
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
print("Apagant...")
|
||||||
|
eyes_turn_off()
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test formes completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 2 — Animació de repeat i direcció
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_animation():
|
||||||
|
"""
|
||||||
|
Prova l'animació amb repeat i les dues direccions.
|
||||||
|
Hauries de veure els LEDs encenent-se un per un en ordre normal i invers.
|
||||||
|
"""
|
||||||
|
print("=== TEST ANIMACIÓ ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print("EYES_OPEN groc, repeat=2, endavant...")
|
||||||
|
eyes_turn_on(EYES_OPEN, YELLOW, repeat=2, forward=True)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
print("EYES_FW taronja, repeat=2, enrere...")
|
||||||
|
eyes_turn_on(EYES_FW, ORANGE, repeat=2, forward=False)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
eyes_turn_off()
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test animació completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 3 — Animacions mode gestos (TFG)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_gesture_animations():
|
||||||
|
"""
|
||||||
|
Prova les animacions específiques del mode gestos.
|
||||||
|
Hauries de veure: doble parpelleig cian, tornada a blanc, marc cian.
|
||||||
|
"""
|
||||||
|
print("=== TEST ANIMACIONS MODE GESTOS ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print("Activant mode gestos (doble parpelleig cian)...")
|
||||||
|
eyes_gesture_mode_on()
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
print("Escoltant gest (marc cian)...")
|
||||||
|
eyes_listening()
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
print("Desactivant mode gestos (torna a blanc)...")
|
||||||
|
eyes_gesture_mode_off()
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
eyes_turn_off()
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test animacions gestos completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 4 — Parpelleig (breathing)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_breathing():
|
||||||
|
"""
|
||||||
|
Verifica que el thread de parpelleig funciona correctament.
|
||||||
|
Hauries de veure la brillantor dels LEDs pujant i baixant suaument.
|
||||||
|
"""
|
||||||
|
print("=== TEST PARPELLEIG (BREATHING) ===")
|
||||||
|
pi = _setup()
|
||||||
|
|
||||||
|
print("EYES_OPEN blanc — observa el parpelleig durant 10 segons...")
|
||||||
|
eyes_turn_on(EYES_OPEN, WHITE)
|
||||||
|
time.sleep(10.0)
|
||||||
|
|
||||||
|
eyes_turn_off()
|
||||||
|
_teardown(pi)
|
||||||
|
print("Test parpelleig completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Execució
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Descomenta el test que vols executar:
|
||||||
|
test_shapes()
|
||||||
|
# test_animation()
|
||||||
|
# test_gesture_animations()
|
||||||
|
# test_breathing()
|
||||||
94
raspi/tests/test_gesture.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
test_gesture.py — Tests del mòdul gesture.py (sensor PAJ7620U2).
|
||||||
|
Executa des del directori Rasp/: python tests/test_gesture.py
|
||||||
|
|
||||||
|
Descomenta la funció que vols provar al final del fitxer.
|
||||||
|
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
import time
|
||||||
|
from gesture import (
|
||||||
|
gesture_setup, gesture_cleanup,
|
||||||
|
read_gesture,
|
||||||
|
GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT,
|
||||||
|
GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_GESTURE_NAMES = {
|
||||||
|
GS_NONE: "Cap (GS_NONE)",
|
||||||
|
GS_FORWARD: "Endavant (GS_FORWARD)",
|
||||||
|
GS_LEFT: "Esquerra (GS_LEFT)",
|
||||||
|
GS_RIGHT: "Dreta (GS_RIGHT)",
|
||||||
|
GS_UP: "Amunt (GS_UP)",
|
||||||
|
GS_DOWN: "Avall (GS_DOWN)",
|
||||||
|
GS_CLOCKWISE: "Horari (GS_CLOCKWISE)",
|
||||||
|
GS_ANTICLOCKWISE: "Antihorari (GS_ANTICLOCKWISE)",
|
||||||
|
GS_WAVE: "Wave (GS_WAVE)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 1 — Connexió i inicialització
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_connection():
|
||||||
|
"""
|
||||||
|
Comprova que el sensor PAJ7620U2 és accessible via I2C.
|
||||||
|
Ha de mostrar 'Gesture sensor init OK' sense errors.
|
||||||
|
"""
|
||||||
|
print("=== TEST CONNEXIÓ PAJ7620U2 ===")
|
||||||
|
|
||||||
|
gesture_setup()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
gesture_cleanup()
|
||||||
|
print("Test connexió completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 2 — Lectura de gestos
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_read_gestures():
|
||||||
|
"""
|
||||||
|
Llegeix gestos durant 30 segons i els mostra per pantalla.
|
||||||
|
Fes gestos davant del sensor per verificar que els detecta correctament:
|
||||||
|
- Mà cap endavant/enrere → GS_FORWARD
|
||||||
|
- Mà cap a l'esquerra → GS_LEFT
|
||||||
|
- Mà cap a la dreta → GS_RIGHT
|
||||||
|
- Mà cap amunt → GS_UP
|
||||||
|
- Mà cap avall → GS_DOWN
|
||||||
|
- Rotació horària → GS_CLOCKWISE
|
||||||
|
- Rotació antihorària → GS_ANTICLOCKWISE
|
||||||
|
- Sacsejada (wave) → GS_WAVE
|
||||||
|
"""
|
||||||
|
print("=== TEST LECTURA GESTOS PAJ7620U2 ===")
|
||||||
|
print("Fes gestos davant del sensor durant 30 segons...")
|
||||||
|
|
||||||
|
gesture_setup()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
inici = time.time()
|
||||||
|
while time.time() - inici < 30:
|
||||||
|
gest = read_gesture()
|
||||||
|
if gest != GS_NONE:
|
||||||
|
nom = _GESTURE_NAMES.get(gest, f"Desconegut ({gest})")
|
||||||
|
print(f" Gest detectat: {nom}")
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
gesture_cleanup()
|
||||||
|
print("Test lectura gestos completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Execució
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Descomenta el test que vols executar:
|
||||||
|
test_connection()
|
||||||
|
# test_read_gestures()
|
||||||
238
raspi/tests/test_motion.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
test_motion.py — Tests individuals del mòdul motion.py.
|
||||||
|
Executa des del directori Rasp/: python tests/test_motion.py
|
||||||
|
|
||||||
|
Descomenta la funció que vols provar al final del fitxer.
|
||||||
|
Assegura't que el venv està activat i pigpiod en marxa (sudo pigpiod -s 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pigpio
|
||||||
|
import motion
|
||||||
|
from motion import (
|
||||||
|
motion_setup, motion_setup_steppers, motion_setup_sensors, motion_cleanup,
|
||||||
|
enable_wheels, enable_arms, enable_syringe,
|
||||||
|
arms_home, syringe_home,
|
||||||
|
distance_to_object,
|
||||||
|
ON, OFF, CW, CCW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pi_connect():
|
||||||
|
pi = pigpio.pi()
|
||||||
|
if not pi.connected:
|
||||||
|
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
|
||||||
|
sys.exit(1)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _setup_motors():
|
||||||
|
"""Setup mínim per a tests de motors (sense sensors I2C)."""
|
||||||
|
pi = _pi_connect()
|
||||||
|
motion_setup_steppers(pi)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _setup_sensors():
|
||||||
|
"""Setup mínim per a tests de sensors I2C (sense steppers)."""
|
||||||
|
pi = _pi_connect()
|
||||||
|
motion_setup_sensors(pi)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _teardown_motors(pi):
|
||||||
|
motion_cleanup()
|
||||||
|
pi.stop()
|
||||||
|
|
||||||
|
def _teardown_sensors(pi):
|
||||||
|
pi.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 1 — Motors pas a pas (rodes)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_motor_unic():
|
||||||
|
"""
|
||||||
|
Prova únicament la roda DRETA: 200 passos endavant i enrere.
|
||||||
|
Primer test a fer — verifica que un sol motor funciona correctament.
|
||||||
|
Hauries de veure la roda girar ~1/5 de volta (reducció 1:5).
|
||||||
|
"""
|
||||||
|
print("=== TEST MOTOR ÚNIC (RODA DRETA) ===")
|
||||||
|
pi = _setup_motors()
|
||||||
|
|
||||||
|
PASSOS = 200
|
||||||
|
|
||||||
|
print(f"Movent roda DRETA {PASSOS} passos endavant...")
|
||||||
|
enable_wheels(ON)
|
||||||
|
motion.wheel_R.move_to(PASSOS)
|
||||||
|
while motion.wheel_R.is_running():
|
||||||
|
time.sleep(0.05)
|
||||||
|
print("Roda dreta endavant: OK")
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f"Movent roda DRETA {PASSOS} passos enrere...")
|
||||||
|
motion.wheel_R.move_to(0)
|
||||||
|
while motion.wheel_R.is_running():
|
||||||
|
time.sleep(0.05)
|
||||||
|
print("Roda dreta enrere: OK")
|
||||||
|
|
||||||
|
enable_wheels(OFF)
|
||||||
|
_teardown_motors(pi)
|
||||||
|
print("Test motor únic completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_motors():
|
||||||
|
"""
|
||||||
|
Prova les dues rodes: avança 200 passos i torna enrere.
|
||||||
|
Executa test_motor_unic() primer per verificar que un motor funciona.
|
||||||
|
Hauries de veure cada roda girar ~1/5 de volta (reducció 1:5).
|
||||||
|
"""
|
||||||
|
print("=== TEST MOTORS (RODES) ===")
|
||||||
|
pi = _setup_motors()
|
||||||
|
|
||||||
|
PASSOS = 200
|
||||||
|
|
||||||
|
print(f"Movent roda DRETA {PASSOS} passos endavant...")
|
||||||
|
enable_wheels(ON)
|
||||||
|
motion.wheel_R.move_to(PASSOS)
|
||||||
|
while motion.wheel_R.is_running():
|
||||||
|
time.sleep(0.05)
|
||||||
|
print("Roda dreta: OK")
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f"Movent roda DRETA {PASSOS} passos enrere...")
|
||||||
|
motion.wheel_R.move_to(0)
|
||||||
|
while motion.wheel_R.is_running():
|
||||||
|
time.sleep(0.05)
|
||||||
|
print("Roda dreta enrere: OK")
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f"Movent roda ESQUERRA {PASSOS} passos endavant...")
|
||||||
|
motion.wheel_L.move_to(PASSOS)
|
||||||
|
while motion.wheel_L.is_running():
|
||||||
|
time.sleep(0.05)
|
||||||
|
print("Roda esquerra: OK")
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f"Movent roda ESQUERRA {PASSOS} passos enrere...")
|
||||||
|
motion.wheel_L.move_to(0)
|
||||||
|
while motion.wheel_L.is_running():
|
||||||
|
time.sleep(0.05)
|
||||||
|
print("Roda esquerra enrere: OK")
|
||||||
|
|
||||||
|
enable_wheels(OFF)
|
||||||
|
_teardown_motors(pi)
|
||||||
|
print("Test motors completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 2 — Homing (finals de carrera)
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_homing_brac():
|
||||||
|
"""
|
||||||
|
Executa el homing únicament dels BRAÇOS.
|
||||||
|
Primer test de homing — verifica que un sol conjunt de finals de carrera funciona.
|
||||||
|
ATENCIÓ: assegura't que els braços tinguin espai per moure's.
|
||||||
|
"""
|
||||||
|
print("=== TEST HOMING BRAÇOS ===")
|
||||||
|
pi = _setup_motors()
|
||||||
|
|
||||||
|
print("Iniciant homing dels BRAÇOS...")
|
||||||
|
arms_home()
|
||||||
|
print("Homing braços: OK")
|
||||||
|
|
||||||
|
_teardown_motors(pi)
|
||||||
|
print("Test homing braços completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_homing():
|
||||||
|
"""
|
||||||
|
Executa el homing complet: braços i xeringa.
|
||||||
|
Executa test_homing_brac() primer per verificar els finals de carrera dels braços.
|
||||||
|
ATENCIÓ: assegura't que els braços i la xeringa tinguin espai per moure's.
|
||||||
|
"""
|
||||||
|
print("=== TEST HOMING COMPLET ===")
|
||||||
|
pi = _setup_motors()
|
||||||
|
|
||||||
|
print("Iniciant homing dels BRAÇOS...")
|
||||||
|
arms_home()
|
||||||
|
print("Homing braços: OK")
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
print("Iniciant homing de la XERINGA...")
|
||||||
|
syringe_home()
|
||||||
|
print("Homing xeringa: OK")
|
||||||
|
|
||||||
|
_teardown_motors(pi)
|
||||||
|
print("Test homing completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 3 — Sensor de distància VL53L0X
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_distance_sensor():
|
||||||
|
"""
|
||||||
|
Llegeix la distància 10 vegades cada 500ms.
|
||||||
|
Posa la mà davant del sensor per verificar que canvia el valor.
|
||||||
|
"""
|
||||||
|
print("=== TEST SENSOR DISTÀNCIA VL53L0X ===")
|
||||||
|
pi = _setup_sensors()
|
||||||
|
|
||||||
|
print("Llegint distància durant 5 segons (posa la mà davant del sensor)...")
|
||||||
|
for i in range(10):
|
||||||
|
dist = distance_to_object()
|
||||||
|
if dist == 65535:
|
||||||
|
print(f" Lectura {i+1:2d}: fora de rang")
|
||||||
|
else:
|
||||||
|
print(f" Lectura {i+1:2d}: {dist} mm")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
_teardown_sensors(pi)
|
||||||
|
print("Test sensor distància completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# TEST 4 — Sensors de línia ADS1115
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
def test_line_sensors():
|
||||||
|
"""
|
||||||
|
Llegeix els dos sensors de línia 10 vegades cada 500ms.
|
||||||
|
Posa el sensor sobre superfícies de diferent color per veure la variació.
|
||||||
|
Valor alt (~9700+) = negre. Valor baix = blanc/clar.
|
||||||
|
"""
|
||||||
|
print("=== TEST SENSORS DE LÍNIA ADS1115 ===")
|
||||||
|
pi = _setup_sensors()
|
||||||
|
|
||||||
|
print("Llegint sensors de línia durant 5 segons...")
|
||||||
|
print(" (posa els sensors sobre blanc i negre per veure la diferència)")
|
||||||
|
for i in range(10):
|
||||||
|
r = motion._chan_r.value
|
||||||
|
l = motion._chan_l.value
|
||||||
|
print(f" Lectura {i+1:2d}: DRETA={r:5d} ESQUERRA={l:5d} error={r-l:+6d}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
_teardown_sensors(pi)
|
||||||
|
print("Test sensors de línia completat.\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================
|
||||||
|
# Execució
|
||||||
|
# ==================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Descomenta el test que vols executar:
|
||||||
|
test_motor_unic() # Primer: prova un sol motor
|
||||||
|
# test_motors() # Segon: prova les dues rodes
|
||||||
|
# test_homing_brac() # Primer homing: només els braços
|
||||||
|
# test_homing() # Homing complet: braços i xeringa
|
||||||
|
# test_distance_sensor()
|
||||||
|
# test_line_sensors()
|
||||||
53
raspi/tests/test_simple_motor.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
test_simple_motor.py — Test mínim de la roda dreta per diagnosticar problemes.
|
||||||
|
Executa des del directori Rasp/tests/: python3 test_simple_motor.py
|
||||||
|
|
||||||
|
Diferències respecte a test_motion.py:
|
||||||
|
- Usa pi.write() directe en lloc de gpio_trigger()
|
||||||
|
- Bucle bloquejant amb time.sleep() en lloc de thread
|
||||||
|
- Sense acceleració, velocitat constant
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pigpio
|
||||||
|
import time
|
||||||
|
|
||||||
|
STEP = 25 # STEP_R_W
|
||||||
|
DIR = 23 # DIR_R_W
|
||||||
|
EN = 6 # EN_W (actiu LOW)
|
||||||
|
|
||||||
|
pi = pigpio.pi()
|
||||||
|
if not pi.connected:
|
||||||
|
print("ERROR: pigpiod no està en marxa")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
pi.set_mode(STEP, pigpio.OUTPUT)
|
||||||
|
pi.set_mode(DIR, pigpio.OUTPUT)
|
||||||
|
pi.set_mode(EN, pigpio.OUTPUT)
|
||||||
|
|
||||||
|
pi.write(EN, 0) # Activa el driver (LOW = ON)
|
||||||
|
pi.write(DIR, 1) # Endavant
|
||||||
|
|
||||||
|
# 500 passos/s → període = 1/500 = 2ms → mig període = 1ms
|
||||||
|
DELAY = 0.001
|
||||||
|
|
||||||
|
print("Movent 200 passos endavant...")
|
||||||
|
for _ in range(200):
|
||||||
|
pi.write(STEP, 1)
|
||||||
|
time.sleep(DELAY)
|
||||||
|
pi.write(STEP, 0)
|
||||||
|
time.sleep(DELAY)
|
||||||
|
|
||||||
|
print("Fet. Esperant 1s...")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
pi.write(DIR, 0) # Enrere
|
||||||
|
print("Movent 200 passos enrere...")
|
||||||
|
for _ in range(200):
|
||||||
|
pi.write(STEP, 1)
|
||||||
|
time.sleep(DELAY)
|
||||||
|
pi.write(STEP, 0)
|
||||||
|
time.sleep(DELAY)
|
||||||
|
|
||||||
|
print("Fet.")
|
||||||
|
pi.write(EN, 1) # Desactiva el driver
|
||||||
|
pi.stop()
|
||||||