diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9a3974f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,345 @@ +# QuiBot Project — Agent Guide + +## Overview + +QuiBot is an educational robotics platform (UPC/UNE collaboration) consisting of a programmable robot with color-block recognition, gesture control, stepper motors, RGB LED eyes, and multiple input methods (web dashboard, Android voice app). The codebase comprises four independent application layers communicating via HTTP JSON APIs. + +``` +[quibot-web Nuxt SPA] ──HTTP──> [backend Express] ──HTTP──> [raspi FastAPI (Pi)] + │ │ + ▼ ▼ + [apk Expo RN app] ──HTTP──> (same backend) [Python hardware drivers] +``` + +**Tech stack**: Python (pigpio, FastAPI) | TypeScript/Express | Nuxt 4/Vue 3 | Expo/React Native +**No database**: All state is in-memory, file-based (`/tmp/quibot-audio/`), or localStorage. + +--- + +## Directory Structure + +``` +quibot/ +├── raspi/ # Raspberry Pi brain — Python, controls hardware +│ ├── main.py # FastAPI server (port 8000) on the Pi +│ ├── quibot.py # Main program: block/gesture threads (like Arduino QuiBot.ino) +│ ├── motion.py # Stepper class, homing, line-following, high-level tasks +│ ├── gesture.py # PAJ7620U2 gesture sensor (I2C, polled at 50ms) +│ ├── blocks.py # TCS34725 color sensor + servo block ejection +│ ├── eyes.py # WS2811 LED matrix (128 LEDs, pigpio waveforms, breathing animation) +│ ├── pins.py # BCM GPIO pin map for all hardware +│ └── tests/ # Manual diagnostic scripts (not automated) +├── backend/ # Local Express server (port 3000), proxy to Pi +│ ├── src/ +│ │ ├── index.ts # Express entry: CORS, JSON parser, /health +│ │ ├── config.ts # Env config: RASPBERRY_PI_HOST, PORT, QUIBOT_TOKEN +│ │ ├── routes/router.ts # Mounts all controllers +│ │ ├── services/raspi.service.ts # Axios proxy layer to Pi FastAPI +│ │ └── controllers/ +│ │ ├── motor.controller.ts # Motor step/stop/upload +│ │ ├── audio.controller.ts # Audio file lifecycle (incoming/locked/processed) +│ │ ├── command.controller.ts # POST /commands proxy to raspi /run +│ │ └── settings.controller.ts # GET/PUT /settings runtime config +│ └── dist/ # Compiled output (generated) +├── quibot-web/ # Nuxt 4 dashboard SPA +│ ├── app/app.vue # Single-page control panel: block queue, D-pad, eye controls, gesture log +│ ├── server/api/ # Nitro server routes proxying to raspi +│ │ ├── motor/step/[direction].post.ts +│ │ └── motor/stop.post.ts +│ ├── nuxt.config.ts # Runtime config: QUIBOT_BASE_URL, QUIBOT_TOKEN +│ └── .output/ # Built Nitro output +├── apk/ # Expo React Native voice recorder ("VoiceDrop") +│ ├── app/index.tsx # Recording screen + upload +│ ├── app/settings.tsx # Backend URL/token configuration (AsyncStorage) +│ └── lib/recorder-settings.ts # AsyncStorage wrapper +├── .gitea/workflows/ # CI/CD pipelines +│ ├── build.yml # Web + backend → zip artifacts + Gitea release +│ └── build-apk.yml # Expo prebuild + signed APK → Gitea release +├── build.sh # Placeholder +└── README.md # Project overview (Catalan) +``` + +--- + +## Raspberry Pi Layer (`raspi/`) + +**Hardware target**: Raspberry Pi Zero 2W controlling a robot with: +- 5 NEMA-style stepper motors (wheels x2, arms x2, syringe) via A4988/TB600 drivers in STEP/DIR mode +- VL53L0X ToF distance sensor (I2C bus 3) +- PAJ7620U2 gesture sensor (I2C bus 3, polled at 50ms) +- TCS34725 color sensor (bit-banged I2C on GPIO22/27) +- ADS1115 ADC for TCRT5000 line-following IR sensors +- WS2811 RGB LED matrix (2x 8x8 = 128 LEDs, GPIO26, pigpio waveforms at -s 1) +- Servo motor (GPIO10 PWM) for block ejection +- Hall-effect endstops on GPIOs 12, 16, 17 +- Optional: I2S audio amp (MAX98357A) + mic (SPH0645) + +### Key files + +- **`pins.py`** — BCM GPIO pin numbering for every component (STEP, DIR, EN pins, I2C lines, endstops, LED_DATA on GPIO26) +- **`motion.py`** — `Stepper` class with AccelStepper-style acceleration profiling via `pigpio.gpio_trigger()`. 5 motor instances (`wheel_R`, `wheel_L`, `arm_R`, `arm_L`, `syringe`). Continuous stepper daemon thread (`_stepper_loop`) at ~100Hz. Homing routines read Hall-effect endstops. Line-following with proportional correction on TCRT5000 values via ADS1115. +- **`gesture.py`** — Raw I2C via `smbus2` (bus 3). Two-register-bank init (~240 total register writes). Polls gesture result registers 0x43/0x44 every 50ms. Returns: GS_NONE, GS_FORWARD, GS_LEFT, GS_RIGHT, GS_UP, GS_DOWN, GS_CLOCKWISE, GS_ANTICLOCKWISE, GS_WAVE +- **`blocks.py`** — TCS34725 RGB reads classified via Manhattan distance against calibrated reference table (BK/RD/GN/BU/YE/OG/VT). Smooth servo movement with 3us micro-steps. +- **`eyes.py`** — 128 WS2811 LEDs via pigpio waveforms at 1us resolution. Pre-defined shapes: EYES_OPEN, EYES_FW, EYES_DOWN, EYES_GESTURE. Breathing thread oscillates brightness 80-170 at 50ms intervals. +- **`quibot.py`** — Main program (equivalent to original Arduino QuiBot.ino). Two threading tasks via FreeRTOS-style pattern: `task_read_blocks()` (color→action mapping) and `task_read_gestures()` (WAVE toggles between block/gesture mode). `threading.Lock` prevents concurrent motor movements. Handles SIGINT/SIGTERM for graceful shutdown. +- **`main.py`** — FastAPI server on port 8000 with CORS. File-based state management using `/tmp/quibot-audio/` (incoming/locked/processed directories). Token auth via query parameter matching `QUIBOT_TOKEN`. + +### Color→Action Mapping (in quibot.py) + +| Color | Action | +|-------|--------| +| RED | Advance forward | +| GREEN | Turn right | +| BLUE | Turn left | +| YELLOW | Take/block pick-up | +| ORANGE | Leave/eject | +| VIOLET | Idle | +| BLACK | Reference / no block | + +### Motor Position Tracking + +`Stepper` class in `motion.py` tracks absolute position in steps via `_pos` and `_target`. Endstops provide physical reference during homing. The stepper loop evaluates acceleration profiles to generate STEP pulses at correct intervals. + +--- + +## Backend Layer (`backend/`) + +**Role**: Express.js HTTP proxy sitting between frontend/mobile and the Raspberry Pi's FastAPI server. Token passthrough, no business logic. + +### Configuration (`.env`, loaded by `config.ts`) + +| Variable | Default | Purpose | +|----------|---------|---------| +| `RASPBERRY_PI_HOST` | `http://raspberrypi.local` | Pi API URL | +| `RASPBERRY_PI_PORT` | `8000` | Pi API port | +| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token for all Pi endpoints | +| `PORT` | `3000` | Backend listen port | + +### Architecture + +``` +index.ts → Express app, CORS, JSON parser, /health endpoint +routes/router.ts → Mounts all controllers under /motor, /audio, /commands, /settings +config.ts → Mutable getter/setter env vars (runtime update via PUT /settings) +raspi.service.ts → Axios proxy methods for every Pi endpoint + multipart file upload handling +``` + +### Controllers + +- **`motor.controller.ts`** — `POST /motor/step/forward`, `/motor/step/backward`, `/motor/stop`. Also `POST /motor/upload` (multer multipart → proxied as FormData to Pi). +- **`audio.controller.ts`** — `GET /audio/incoming`, `POST /audio/lock/:filename`, `/unlock/:filename`, `/cancel/:filename`, `/process/:filename`. All proxy to raspi audio file lifecycle endpoints. +- **`command.controller.ts`** — `POST /commands { task }` → proxied to raspi `/run?task=...&token=...` +- **`settings.controller.ts`** — `GET /settings` returns config; `PUT /settings` updates `raspberryPi.host`, `raspberryPi.port`, `token` at runtime. + +### Build/Run + +```bash +cd backend +npm install # Installs express, axios, multer, dotenv, cors, typescript +npx tsc # Compiles to dist/ +node dist/index.js # Or use tsx/nodemon for dev +``` + +--- + +## Web Frontend (`quibot-web/`) + +**Stack**: Nuxt 4 (Nitro) + Vue 3. Single-page dashboard SPA. + +### Runtime config (`nuxt.config.ts`) + +| Key | Default | Purpose | +|-----|---------|---------| +| `QUIBOT_BASE_URL` | `http://quibot:8000` | Base URL for raspi FastAPI | +| `QUIBOT_TOKEN` | `MY_SECRET_TOKEN` | Auth token | + +### UI Panels (`app/app.vue` — single-file SPA, 1369 lines) + +1. **Block Queue Panel** — Displays color blocks in a queue (localStorage persistence + demo fallback). Shows action descriptions per color. +2. **Motion Controls** — D-pad grid: up=forward, down=back, left/right=turns, center=stop. Sends `$fetch('/api/motor/step/forward')` etc. +3. **Eye Controls** — Shape selector (open/forward/down/gesture), 8-color picker. Calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`. +4. **Gesture Sensor Panel** — Toggle between Block Mode / Gesture Mode. Gesture detection history log. Reference table of all 8 gestures. + +### State & Styling +- Dark/light theme via CSS custom properties, persisted in localStorage. +- Block queue data stored in localStorage with demo fallback. +- Toast notifications for success/error feedback. +- Responsive layout with CSS Grid (mobile-adaptive). + +### Server Routes (`server/api/`) + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/motor/stop` | Proxies to raspi `/motor/stop` | +| POST | `/api/motor/step/:direction` | Proxies to raspi `/motor/step/forward\|backwards` | + +**Note**: The frontend also calls `POST /api/eye/shape`, `/api/eye/color`, `/api/eye/on`, `/api/eye/off`, `/api/gesture/on`, `/api/gesture/off` — server routes for these may need to be created (frontend references them but they don't have explicit server handlers yet). + +### Build/Run + +```bash +cd quibot-web +npm install +npx nuxt build # Produces .output/ +# Dev mode: +npx nuxt dev +``` + +CI builds the Nuxt output and zips it for distribution. + +--- + +## Android APK (`apk/`) + +**Stack**: Expo SDK ~54 + React Native 0.81.5 + Expo Router ~6.0. App name: "VoiceDrop". + +### Features +- Records audio via `expo-av` (`Audio.Recording` — produces .m4a) +- Auto-uploads on stop as multipart form data to configurable backend URL +- Displays recording timer, status messages, raw server response (first 400 chars) + +### Screens +- **`app/index.tsx`** — Recorder screen: start/stop recording, upload, status +- **`app/settings.tsx`** — Backend URL, Bearer token, form field name. Saved to AsyncStorage under `recorder.*` namespace. + +### Persistence +Settings stored in `@react-native-async-storage/async-storage` (keys: `recorder.backendUrl`, `recorder.authToken`, `recorder.fieldName`). + +### Build/Run + +```bash +cd apk +npm install +npx expo start # Dev mode +# Production APK (local): +./build.sh # expo prebuild → generates android/ → gradle assembleRelease +``` + +CI: `build-apk.yml` runs expo prebuild, decodes keystore from secrets, builds signed release APK. + +--- + +## Complete API Reference + +### Raspberry Pi FastAPI (`raspi/main.py`) — port 8000 + +| Method | Path | Params/Body | Description | +|--------|------|-------------|-------------| +| POST | `/run` | query: `task`, `token` | Runs whitelisted system commands (restart_nginx, uptime, update) | +| POST | `/motor/step/forward` | query: `token` | Starts motor forward (daemon thread) | +| POST | `/motor/step/backwards` | query: `token` | Starts motor backward (daemon thread) | +| POST | `/motor/stop` | query: `token` | Disables motor driver (GPIO EN HIGH) | +| POST | `/audio/upload` | multipart: `file`, query: `format` | Saves to `/tmp/quibot-audio/incoming/`, returns filename + lock_url | +| GET | `/audio/incoming` | — | Lists files with size and modified time | +| POST | `/audio/lock/{filename}` | — | incoming → locked (claim for processing) | +| POST | `/audio/unlock/{filename}` | — | locked → incoming (release) | +| POST | `/audio/cancel/{filename}` | — | locked → incoming (cancel) | +| POST | `/audio/process/{filename}` | — | locked → processed | + +**Auth**: Query parameter `token` matching `QUIBOT_TOKEN` env var (default: `MY_SECRET_TOKEN`). + +### Backend Express (`backend/`) — port 3000 + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| GET | `/health` | — | Returns settings object | +| POST | `/commands` | `{ task }` | Proxy to raspi `/run` | +| POST | `/motor/step/forward` | — | Motor forward proxy | +| POST | `/motor/step/backward` | — | Motor backward proxy (maps to raspi `/backwards`) | +| POST | `/motor/stop` | — | Motor stop proxy | +| POST | `/motor/upload` | multipart file | Audio upload via multer in-memory buffer | +| GET | `/audio/incoming` | — | List incoming audio files | +| POST | `/audio/lock/:filename` | — | Lock audio file | +| POST | `/audio/unlock/:filename` | — | Unlock audio file | +| POST | `/audio/cancel/:filename` | — | Cancel locked audio | +| POST | `/audio/process/:filename` | — | Mark processed | +| GET | `/settings` | — | Returns config | +| PUT | `/settings` | `{ raspberryPi: { host, port }, token }` | Update runtime config | + +--- + +## Command Flow Examples + +### Web → Motion Control +``` +User clicks "Forward" in D-pad + → $fetch('/api/motor/step/forward', { method: 'POST' }) + → Nuxt Nitro route: server/api/motor/step/[direction].post.ts + → $fetch(config.quibotBaseUrl + '/motor/step/forward', { query: { token } }) + → raspi FastAPI /motor/step/forward + → motor_step("forward") in daemon thread + → step_motor(200, DIR, 1ms pulses) +``` + +### Block Processing (internal to Pi) +``` +Child inserts colored block → quibot.py task_read_blocks() polls distance sensor + → When detected <80mm: read_block_color() via TCS34725 + → Manhattan distance classification against color lookup table + → RED: eyes_turn_on(EYES_FW, DARK_RED, 2) + _execute_action(task_move_to, CROSSING) + → enable_wheels(ON) → follow_line_loop(speed) (proportional on TCRT5000) + → After action: servo_move_to(EJECT_POSITION) +``` + +### APK → Audio Upload +``` +User stops recording in VoiceDrop → expo-av .m4a file + → POST {backendUrl} with FormData {fieldName: "file"} + Bearer auth + → raspi FastAPI saves to /tmp/quibot-audio/incoming/{uuid}.wav + → Returns: { status: "received", filename, lock_url } +``` + +### Gesture Mode Toggle +``` +User toggles mode in web UI + → _execute_action() locks mutex + → If gesture mode: eyes_gesture_mode_on() (double cyan flash on 128-LED matrix) + → Eyes breathing thread at MAX_BR(170) brightness +``` + +--- + +## CI/CD (`/.gitea/workflows/`) + +### `build.yml` — Web + Backend +- Triggers: Push to `master` +- Builds web: `npm install && npx nuxt build`, zips `.output/` +- Builds backend: Zips entire `backend/` directory +- Creates Gitea release "latest" with both zip artifacts + +### `build-apk.yml` — Mobile +- Triggers: Push to `master` +- expo prebuild → decode keystore from secrets → `./gradlew assembleRelease` +- Creates Gitea release with APK artifact + +--- + +## Testing + +All tests in `raspi/tests/` are **manual diagnostic scripts** (not automated frameworks). Each test is run independently by uncommenting the desired function call at the bottom of the file. Requirements: + +- `sudo pigpiod -s 1` daemon running +- Python venv activated with hardware dependencies installed +- Pi connected to robot hardware + +| Test | What it verifies | +|------|-----------------| +| `test_simple_motor.py` | Low-level motor driver via direct GPIO writes | +| `test_motion.py` | Wheel steppers, arm/syringe homing, VL53L0X distance, ADS1115 line sensors | +| `test_blocks.py` | Servo sweep (open/eject/open), color sensor readings, raw RGB calibration | +| `test_gesture.py` | PAJ7620U2 I2C connection + 30-second gesture capture | +| `test_eyes.py` | LED shape/color rendering, animation repeat/direction, gesture animations, breathing | + +--- + +## Key Conventions + +1. **Token auth** is always a query parameter matching `QUIBOT_TOKEN` (default: `MY_SECRET_TOKEN`) +2. **No database** — use filesystem for persistence, localStorage for web client state +3. **Backend is a dumb proxy** — no business logic, just forwards HTTP requests with token passthrough +4. **Motor commands are fire-and-forget** — motor runs in daemon thread until `/motor/stop` +5. **Audio lifecycle**: incoming → locked (claim) → processed OR unlocked (release) / cancelled +6. **Eyes breathing** runs continuously at MIN_BR(80)-MAX_BR(170) brightness in background +7. **`quibot.py` owns block/gesture autonomy** — blocks are processed internally on the Pi without backend/web involvement +8. **All paths use forward slashes** in URLs; kebab-case for params diff --git a/README.md b/README.md index f04d1fc..247d287 100644 --- a/README.md +++ b/README.md @@ -5,79 +5,18 @@ Normes del repositori: - S’ha de treballar en branques pròpies (opcional) - No es pot modificar la carpeta d’altres zones +# Estructura del projecte -# Nuxt Minimal Starter +- robot: Carpeta amb totes les instruccions necessaries per poder reconstruir el robot +- raspi: Carpeta amb el codi necessari que cal carregar dins de la raspberry pi +- backend: Backend de tot el controlador del robot. S'executa en un portatil en local +- quibot-web: Frontend web que es comunica amb el backend +- apk: Aplicació Android que també es comunica amb el backend. -Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. +# Instalació -## Setup +Necesitem primer una raspberry operativa. Un cop operativa cal descarregar el codi que controla el robot. Es necesari tenir python. -Make sure to install dependencies: +- Cal passar la carpeta backend a la raspberry pi -```bash -# npm -npm install -# pnpm -pnpm install - -# yarn -yarn install - -# bun -bun install -``` - -## Development Server - -Start the development server on `http://localhost:3000`: - -```bash -# npm -npm run dev - -# pnpm -pnpm dev - -# yarn -yarn dev - -# bun -bun run dev -``` - -## Production - -Build the application for production: - -```bash -# npm -npm run build - -# pnpm -pnpm build - -# yarn -yarn build - -# bun -bun run build -``` - -Locally preview production build: - -```bash -# npm -npm run preview - -# pnpm -pnpm preview - -# yarn -yarn preview - -# bun -bun run preview -``` - -Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/UPCLogo.jpg b/UPCLogo.jpg new file mode 100644 index 0000000..b438d94 Binary files /dev/null and b/UPCLogo.jpg differ diff --git a/apk/app/index.tsx b/apk/app/index.tsx index c77b832..37848e9 100644 --- a/apk/app/index.tsx +++ b/apk/app/index.tsx @@ -1,6 +1,7 @@ import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av"; import { router, useFocusEffect } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Svg, { Path } from "react-native-svg"; import { ActivityIndicator, Alert, @@ -12,8 +13,8 @@ import { Text, View, } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; import { loadRecorderSettings } from "@/lib/recorder-settings"; +import { getStrings, type Locale, t } from "@/lib/translations"; function formatDuration(durationMs: number) { const totalSeconds = Math.floor(durationMs / 1000); @@ -50,14 +51,16 @@ export default function RecorderScreen() { const [backendUrl, setBackendUrl] = useState(""); const [authToken, setAuthToken] = useState(""); const [fieldName, setFieldName] = useState("file"); + const [locale, setLocale] = useState("ca"); + const [strings, setStrings] = useState(() => getStrings("ca")); const [recording, setRecording] = useState(null); const [recordingUri, setRecordingUri] = useState(null); const [recordingMs, setRecordingMs] = useState(0); - const [statusMessage, setStatusMessage] = useState( - "Ready to record and send audio.", - ); + const [statusMessage, setStatusMessage] = useState(""); const [responsePreview, setResponsePreview] = useState(""); const [isUploading, setIsUploading] = useState(false); + const [isHolding, setIsHolding] = useState(false); + const recordingRef = useRef(null); const refreshSettings = useCallback(() => { let isMounted = true; @@ -73,9 +76,11 @@ export default function RecorderScreen() { setBackendUrl(settings.backendUrl); setAuthToken(settings.authToken); setFieldName(settings.fieldName); + setLocale(settings.language); + setStrings(getStrings(settings.language)); } catch { if (isMounted) { - setStatusMessage("Could not load saved backend settings."); + setStatusMessage(strings.loadError); } } } @@ -123,10 +128,10 @@ export default function RecorderScreen() { const permission = await Audio.requestPermissionsAsync(); if (!permission.granted) { - setStatusMessage("Microphone permission was denied."); + setStatusMessage(strings.micPermissionDenied); Alert.alert( - "Microphone access required", - "Enable microphone access to record audio.", + strings.micAccessRequiredTitle, + strings.micAccessRequiredMsg, ); return; } @@ -144,26 +149,28 @@ export default function RecorderScreen() { Audio.RecordingOptionsPresets.HIGH_QUALITY, ); + recordingRef.current = result.recording; setRecording(result.recording); setRecordingMs(0); - setStatusMessage("Recording in progress."); + setStatusMessage(strings.recording); } catch (error) { - setStatusMessage("Recording could not be started."); + setStatusMessage(strings.couldNotStartRecording); Alert.alert( - "Recording failed", - error instanceof Error ? error.message : "Unknown recording error.", + strings.recordingFailedTitle, + error instanceof Error ? error.message : "", ); } } - async function stopRecording() { - if (!recording) { + async function stopRecordingAndUpload() { + if (!recordingRef.current) { return; } try { - const activeRecording = recording; + const activeRecording = recordingRef.current; const currentStatus = await activeRecording.getStatusAsync(); + const durationMillis = currentStatus.durationMillis ?? 0; await activeRecording.stopAndUnloadAsync(); await Audio.setAudioModeAsync({ @@ -172,25 +179,86 @@ export default function RecorderScreen() { }); const uri = activeRecording.getURI(); + recordingRef.current = null; setRecording(null); - setRecordingMs(currentStatus.durationMillis ?? recordingMs); - setRecordingUri(uri); - setStatusMessage( - uri ? "Recording finished. Preparing to send voice message." : "Recording finished.", - ); + setRecordingMs(durationMillis); - if (uri && backendUrl.trim()) { - await uploadRecording(uri); + if (!uri) { + setStatusMessage(strings.readyToRecord); + return; + } + + setRecordingUri(uri); + setStatusMessage(strings.finishedUpload); + + const trimmedUrl = backendUrl.trim().replace(/\/+$/, ''); + const uploadUrl = trimmedUrl.endsWith('/audio/upload') + ? trimmedUrl + : `${trimmedUrl}/audio/upload`; + if (uploadUrl) { + 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 = {}; + if (authToken.trim()) { + headers.Authorization = `Bearer ${authToken.trim()}`; + } + + const response = await fetch(uploadUrl, { + method: "POST", + headers, + body: formData, + }); + + const responseText = await response.text(); + setResponsePreview(responseText.slice(0, 400)); + + if (!response.ok) { + throw new Error(`${response.status}. ${responseText}`); + } + + setStatusMessage(strings.voiceMessageSent); + } catch (error) { + setStatusMessage(strings.uploadFailed); + Alert.alert( + strings.uploadFailed, + error instanceof Error ? error.message : "", + ); + } + } else { + setStatusMessage(strings.noBackendUrl); } } catch (error) { - setStatusMessage("Recording could not be stopped cleanly."); + recordingRef.current = null; + setRecording(null); + setStatusMessage(strings.stopFailedTitle); Alert.alert( - "Stop failed", - error instanceof Error ? error.message : "Unknown stop error.", + strings.stopFailedTitle, + error instanceof Error ? error.message : "", ); } } + async function handlePressIn() { + if (isUploading) return; + setIsHolding(true); + await startRecording(); + } + + async function handlePressOut() { + if (!isHolding) return; + setIsHolding(false); + await stopRecordingAndUpload(); + } + async function uploadRecording(uriOverride?: string) { const targetUri = uriOverride ?? recordingUri; @@ -198,16 +266,19 @@ export default function RecorderScreen() { return; } - const trimmedUrl = backendUrl.trim(); + const trimmedUrl = backendUrl.trim().replace(/\/+$/, ''); + const uploadUrl = trimmedUrl.endsWith('/audio/upload') + ? trimmedUrl + : `${trimmedUrl}/audio/upload`; - if (!trimmedUrl) { - Alert.alert("Missing backend URL", "Enter the backend endpoint first."); + if (!uploadUrl) { + Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg); return; } try { setIsUploading(true); - setStatusMessage("Uploading recording."); + setStatusMessage(strings.uploadingRecording); setResponsePreview(""); const mimeType = buildMimeType(targetUri); @@ -226,7 +297,7 @@ export default function RecorderScreen() { headers.Authorization = `Bearer ${authToken.trim()}`; } - const response = await fetch(trimmedUrl, { + const response = await fetch(uploadUrl, { method: "POST", headers, body: formData, @@ -236,23 +307,30 @@ export default function RecorderScreen() { setResponsePreview(responseText.slice(0, 400)); if (!response.ok) { - throw new Error(`Upload failed with ${response.status}. ${responseText}`); + throw new Error(`${response.status}. ${responseText}`); } - setStatusMessage("Upload complete."); + setStatusMessage(strings.uploadComplete); } catch (error) { - setStatusMessage("Upload failed."); + setStatusMessage(strings.uploadFailed); Alert.alert( - "Upload failed", - error instanceof Error ? error.message : "Unknown upload error.", + strings.uploadFailed, + error instanceof Error ? error.message : "", ); } finally { setIsUploading(false); } } + const releaseLabel = t("releaseToStop", locale); + const holdLabel = t("holdToRecord", locale); + const openSettingsLabel = t("openSettingsHint", locale); + const appTitleLabel = t("appTitle", locale); + const recorderTitleLabel = t("recorderTitle", locale); + const serverResponseLabel = t("serverResponse", locale); + return ( - + - Assistant Voice + {appTitleLabel} - router.push("/settings")} style={styles.settingsLink}> - Settings + router.push("/settings")} + hitSlop={10} + style={styles.settingsCog} + > + + + + - - Record a voice message and send it to your backend. - - + {formatDuration(recordingMs)} {isUploading ? ( ) : ( - - {recording ? "Stop" : "Record"} - + + + + + )} - {statusMessage} + {statusMessage || strings.readyToRecord} - {backendUrl.trim() - ? recording - ? "Tap again when you finish speaking." - : "Tap the button to start a new voice message." - : "Open settings to add your backend URL before sending voice messages."} + {isHolding + ? releaseLabel + : backendUrl.trim() + ? holdLabel + : openSettingsLabel} {responsePreview ? ( - Server response + {serverResponseLabel} {responsePreview} ) : null} - + ); } @@ -333,17 +440,17 @@ const styles = StyleSheet.create({ flex: 1, }, content: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingVertical: 32, paddingHorizontal: 20, - paddingBottom: 32, - paddingTop: 8, gap: 18, }, hero: { - backgroundColor: "#13304a", - borderRadius: 28, + backgroundColor: "transparent", paddingHorizontal: 22, - paddingVertical: 24, - gap: 12, + paddingTop: 40, }, heroTopRow: { alignItems: "center", @@ -351,7 +458,6 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, heroBadge: { - alignSelf: "flex-start", backgroundColor: "#f2b15d", borderRadius: 999, paddingHorizontal: 12, @@ -364,22 +470,14 @@ const styles = StyleSheet.create({ letterSpacing: 0.5, textTransform: "uppercase", }, - settingsLink: { - borderColor: "#58718d", + settingsCog: { + alignItems: "center", + justifyContent: "center", + width: 40, + height: 40, borderRadius: 999, - borderWidth: 1, - paddingHorizontal: 12, - paddingVertical: 7, - }, - settingsLinkText: { - color: "#d3deea", - fontSize: 13, - fontWeight: "700", - }, - subtitle: { - color: "#d3deea", - fontSize: 16, - lineHeight: 22, + backgroundColor: "#13304a", + marginLeft: 12, }, panel: { backgroundColor: "#fffaf1", @@ -388,6 +486,8 @@ const styles = StyleSheet.create({ borderWidth: 1, gap: 12, padding: 18, + alignSelf: "center", + maxWidth: 340, }, meterValueCentered: { color: "#d04f2d", @@ -404,17 +504,21 @@ const styles = StyleSheet.create({ width: 164, alignSelf: "center", }, - recordButton: { - backgroundColor: "#d04f2d", + idleButton: { + backgroundColor: "#13304a", }, - stopButton: { - backgroundColor: "#8c1c13", + holdingButton: { + backgroundColor: "#d04f2d", + transform: [{ scale: 1.08 }], }, micButtonText: { color: "#fff6f3", - fontSize: 24, + fontSize: 20, fontWeight: "800", }, + recordingLabel: { + fontSize: 18, + }, buttonDisabled: { opacity: 0.45, }, @@ -422,11 +526,13 @@ const styles = StyleSheet.create({ color: "#1f2d3d", fontSize: 15, lineHeight: 21, + textAlign: "center", }, helperText: { color: "#665f54", fontSize: 13, lineHeight: 18, + textAlign: "center", }, responseBox: { backgroundColor: "#f7f0e0", @@ -440,6 +546,7 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "700", textTransform: "uppercase", + textAlign: "center", }, responseText: { color: "#36475a", diff --git a/apk/app/settings.tsx b/apk/app/settings.tsx index 6271ece..9dcf8fd 100644 --- a/apk/app/settings.tsx +++ b/apk/app/settings.tsx @@ -1,3 +1,4 @@ +import { Picker } from "@react-native-picker/picker"; import { router } from "expo-router"; import { useEffect, useState } from "react"; import { @@ -16,11 +17,20 @@ import { loadRecorderSettings, saveRecorderSettings, } from "@/lib/recorder-settings"; +import { AVAILABLE_LOCALES, t, type Locale, getStrings } from "@/lib/translations"; + +function localeLabel(locale: Locale) { + if (locale === "ca") return "Catal\u00e0"; + if (locale === "en") return "English"; + return locale; +} export default function SettingsScreen() { const [backendUrl, setBackendUrl] = useState(""); const [authToken, setAuthToken] = useState(""); const [fieldName, setFieldName] = useState("file"); + const [language, setLanguage] = useState("ca"); + const [strings, setStrings] = useState(() => getStrings("ca")); useEffect(() => { let isMounted = true; @@ -36,9 +46,11 @@ export default function SettingsScreen() { setBackendUrl(settings.backendUrl); setAuthToken(settings.authToken); setFieldName(settings.fieldName); + setLanguage(settings.language); + setStrings(getStrings(settings.language)); } catch { if (isMounted) { - Alert.alert("Settings error", "Could not load backend settings."); + Alert.alert(strings.loadError, strings.loadError); } } } @@ -50,19 +62,28 @@ export default function SettingsScreen() { }; }, []); + const langStrings = getStrings(language); + async function handleSave() { try { await saveRecorderSettings({ authToken, backendUrl, fieldName, + language, }); + setStrings(getStrings(language)); router.back(); } catch { - Alert.alert("Save failed", "Could not save backend settings."); + Alert.alert(langStrings.saveError, langStrings.saveError); } } + function handleLanguageChange(val: Locale) { + setLanguage(val); + setStrings(getStrings(val)); + } + return ( router.back()} style={styles.navButton}> - Back + {langStrings.back} - Settings + {langStrings.settingsTitle} - Save + {langStrings.save} - Backend URL + {langStrings.backendUrl} - Bearer token + {langStrings.bearerToken} - Form field name + {langStrings.formFieldName} - The recording is uploaded as multipart field `{fieldName.trim() || "file"}`. + {t("helperText", language, fieldName.trim() || "file")} + + + {langStrings.languageTitle} + + + {AVAILABLE_LOCALES.map((loc) => ( + + ))} + + + @@ -199,4 +239,14 @@ const styles = StyleSheet.create({ fontSize: 13, lineHeight: 18, }, + pickerWrapper: { + backgroundColor: "#f7f0e0", + borderColor: "#d9ccb5", + borderRadius: 16, + borderWidth: 1, + overflow: "hidden", + }, + picker: { + height: 50, + }, }); diff --git a/apk/lib/recorder-settings.ts b/apk/lib/recorder-settings.ts index f7237c4..153d63b 100644 --- a/apk/lib/recorder-settings.ts +++ b/apk/lib/recorder-settings.ts @@ -1,15 +1,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { Locale } from "./translations"; + export const STORAGE_KEYS = { authToken: "recorder.authToken", backendUrl: "recorder.backendUrl", fieldName: "recorder.fieldName", + language: "recorder.language", }; export type RecorderSettings = { authToken: string; backendUrl: string; fieldName: string; + language: Locale; }; export async function loadRecorderSettings(): Promise { @@ -17,6 +21,7 @@ export async function loadRecorderSettings(): Promise { STORAGE_KEYS.backendUrl, STORAGE_KEYS.authToken, STORAGE_KEYS.fieldName, + STORAGE_KEYS.language, ]); const values = Object.fromEntries(entries); @@ -25,6 +30,7 @@ export async function loadRecorderSettings(): Promise { authToken: values[STORAGE_KEYS.authToken] ?? "", backendUrl: values[STORAGE_KEYS.backendUrl] ?? "", fieldName: values[STORAGE_KEYS.fieldName] ?? "file", + language: (values[STORAGE_KEYS.language] as Locale) ?? "ca", }; } @@ -33,5 +39,6 @@ export async function saveRecorderSettings(settings: RecorderSettings) { [STORAGE_KEYS.backendUrl, settings.backendUrl], [STORAGE_KEYS.authToken, settings.authToken], [STORAGE_KEYS.fieldName, settings.fieldName || "file"], + [STORAGE_KEYS.language, settings.language], ]); } diff --git a/apk/lib/translations/index.ts b/apk/lib/translations/index.ts new file mode 100644 index 0000000..76eb621 --- /dev/null +++ b/apk/lib/translations/index.ts @@ -0,0 +1,100 @@ +export type TranslationKeys = ReturnType; + +export function en() { + return { + appTitle: "Quibot Control", + settingsTitle: "Settings", + back: "Back", + save: "Save", + backendUrl: "Backend URL", + bearerToken: "Bearer token", + formFieldName: "Form field name", + tokenOptional: "Optional", + fieldNamePlaceholder: "file", + urlPlaceholder: "https://api.example.com/upload", + helperText: `The recording is uploaded as multipart field '{field}'.`, + savedAlert: "Settings saved.", + loadError: "Could not load backend settings.", + saveError: "Could not save backend settings.", + languageTitle: "Language", + recorderTitle: "Voice recorder", + readyToRecord: "Ready to record.", + recording: "Recording...", + micPermissionDenied: "Microphone permission was denied.", + micAccessRequiredTitle: "Microphone access required", + micAccessRequiredMsg: "Enable microphone access to record audio.", + couldNotStartRecording: "Could not start recording.", + recordingFailedTitle: "Recording failed", + finishedUpload: "Recording finished. Uploading...", + voiceMessageSent: "Voice message sent.", + uploadFailed: "Upload failed.", + noBackendUrl: "Recording finished. No backend URL set.", + stopFailedTitle: "Stop failed", + missingBackendUrlTitle: "Missing backend URL", + missingBackendUrlMsg: "Enter the backend endpoint first.", + uploadingRecording: "Uploading recording.", + uploadComplete: "Upload complete.", + serverResponse: "Server response", + releaseToStop: "Release your finger to stop recording and send the audio.", + holdToRecord: "Hold the microphone button to record a voice message. Release to send it immediately.", + openSettingsHint: "Open settings to add your backend URL before sending voice messages.", + }; +} + +export function ca() { + return { + appTitle: "Quibot Control", + settingsTitle: "Configuraci\u00f3", + back: "Enrere", + save: "Desa", + backendUrl: "URL del servidor", + bearerToken: "Bearer token", + formFieldName: "Nom del camp del formulari", + tokenOptional: "Opcional", + fieldNamePlaceholder: "file", + urlPlaceholder: "https://api.example.com/upload", + helperText: `La gravaci\u00f3 es penja com el camp multipart '{field}'.`, + savedAlert: "Configuraci\u00f3 desada.", + loadError: "No s'han pogut carregar les configuracions.", + saveError: "No s'han pogut desar les configuracions.", + languageTitle: "Llenguatge", + recorderTitle: "Enregistrador de veu", + readyToRecord: "Preparat per enregistrar.", + recording: "Enregistrant...", + micPermissionDenied: "S'ha denegat el perm\u00eds del micr\u00f2fon.", + micAccessRequiredTitle: "Acc\u00e9s al micr\u00f2fon necess\u00e0ri", + micAccessRequiredMsg: "Activa l'acc\u00e9s al micr\u00f2fon per enregistrar \u00e0udio.", + couldNotStartRecording: "No s'ha pogut iniciar l'enregistrament.", + recordingFailedTitle: "L'enregistrament ha fallat", + finishedUpload: "Gravaci\u00f3 finalitzada. S'est\u00e0 penjant...", + voiceMessageSent: "Missatge de veu enviat.", + uploadFailed: "No s'ha pogut penjar.", + noBackendUrl: "Gravaci\u00f3 finalitzada. No hi ha URL configurada.", + stopFailedTitle: "S'ha aturat", + missingBackendUrlTitle: "Falta la URL del servidor", + missingBackendUrlMsg: "Introdueix primer l'URL del servidor.", + uploadingRecording: "S'est\u00e0 penjant la gravaci\u00f3.", + uploadComplete: "Penjada completada.", + serverResponse: "Resposta del servidor", + releaseToStop: "Allibera el dit per aturar l'enregistrament i enviar l'\u00e0udio.", + holdToRecord: "Mant\u00e9s premut el micr\u00f2fon per enregistrar un missatge de veu. Allibera'l per enviar-lo immediatament.", + openSettingsHint: "Obre la configuraci\u00f3 per afegir l'URL del servidor abans d'enviar missatges de veu.", + }; +} + +const translations = { en, ca }; +export type Locale = keyof typeof translations; +const DEFAULT_LOCALE: Locale = "ca"; +export const AVAILABLE_LOCALES: readonly Locale[] = Object.keys(translations) as Locale[]; + +export function getStrings(locale: Locale) { + const fn = translations[locale] ?? en; + return fn(); +} + +export function t(key: keyof ReturnType, locale: Locale, field?: string) { + const strings = getStrings(locale); + const value = strings[key as keyof typeof strings]; + if (typeof value !== "string") return String(value); + return field ? value.replace("{field}", field) : value; +} diff --git a/apk/package-lock.json b/apk/package-lock.json index f930745..ce11d67 100644 --- a/apk/package-lock.json +++ b/apk/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-picker/picker": "^2.11.4", "expo": "~54.0.33", "expo-av": "~16.0.8", "expo-router": "~6.0.23", @@ -19,6 +20,7 @@ "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.5", "react-native-web": "~0.21.0" }, "devDependencies": { @@ -2769,6 +2771,19 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-picker/picker": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz", + "integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -4538,6 +4553,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -5061,6 +5082,56 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5277,6 +5348,61 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -5346,6 +5472,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -8736,6 +8874,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -9337,6 +9481,18 @@ "node": ">=10" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -10243,6 +10399,20 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.15.5", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", + "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", diff --git a/apk/package.json b/apk/package.json index 6baa31a..b72fbdc 100644 --- a/apk/package.json +++ b/apk/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-picker/picker": "^2.11.4", "expo": "~54.0.33", "expo-av": "~16.0.8", "expo-router": "~6.0.23", @@ -20,8 +21,9 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", - "react-native-web": "~0.21.0", - "react-native-screens": "~4.16.0" + "react-native-screens": "~4.16.0", + "react-native-svg": "^15.15.5", + "react-native-web": "~0.21.0" }, "devDependencies": { "@types/react": "~19.1.0", diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e285a33 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +# Raspberry Pi connection config +RASPBERRY_PI_HOST=http://raspberrypi.local +RASPBERRY_PI_PORT=8000 + +# Auth token for API endpoints +QUIBOT_TOKEN=MY_SECRET_TOKEN + +# Backend server config +PORT=3000 diff --git a/backend/.gitignore b/backend/.gitignore index 0540009..aa0926a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,4 @@ -__pycache__/ -venv/ \ No newline at end of file +node_modules/ +dist/ +.env +*.log diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..f4c880b --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1919 @@ +{ + "name": "quibot-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quibot-backend", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "multer": "^1.4.5-lts.1", + "openai": "^6.44.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", + "@types/node": "^22.19.21", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/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/agent-base/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/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/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/https-proxy-agent/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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openai": { + "version": "6.44.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", + "integrity": "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..456dfa1 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "quibot-backend", + "version": "1.0.0", + "description": "QuiBot robot controller backend - runs on local laptop", + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "axios": "^1.7.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "multer": "^1.4.5-lts.1", + "openai": "^6.44.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", + "@types/node": "^22.19.21", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/backend/quibot-audio-1781783002989.txt b/backend/quibot-audio-1781783002989.txt new file mode 100644 index 0000000..1c79d4a --- /dev/null +++ b/backend/quibot-audio-1781783002989.txt @@ -0,0 +1 @@ +Col·la, pítalo, la ola, ola. diff --git a/backend/quibot-audio-1781783032108.txt b/backend/quibot-audio-1781783032108.txt new file mode 100644 index 0000000..78ddaff --- /dev/null +++ b/backend/quibot-audio-1781783032108.txt @@ -0,0 +1 @@ +Hola, què tal, hola, hola, hola, hola... diff --git a/backend/quibot-audio-1781783047628.txt b/backend/quibot-audio-1781783047628.txt new file mode 100644 index 0000000..6d8a56e --- /dev/null +++ b/backend/quibot-audio-1781783047628.txt @@ -0,0 +1 @@ +Hola, que tal, bon dia. diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..7c71801 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,34 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +let _raspberryHost = process.env.RASPBERRY_PI_HOST ?? 'http://raspberrypi.local'; +let _raspberryPort = Number(process.env.RASPBERRY_PI_PORT) || 8000; +let _token = process.env.QUIBOT_TOKEN ?? 'MY_SECRET_TOKEN'; +const APP_PORT = Number(process.env.PORT) || 5000; + +export const getRaspberryHost = () => _raspberryHost; +export const getRaspberryPort = () => _raspberryPort; +export const getToken = () => _token; + +export function setRaspberryHost(host: string) { + _raspberryHost = host; +} + +export function setRaspberryPort(port: number) { + _raspberryPort = port; +} + +export function setToken(token: string) { + _token = token; +} + +export const getConfig = () => ({ + raspberryPi: { + host: getRaspberryHost(), + port: getRaspberryPort(), + }, + token: getToken(), +}); + +export const getAppPort = () => APP_PORT; diff --git a/backend/src/controllers/audio.controller.ts b/backend/src/controllers/audio.controller.ts new file mode 100644 index 0000000..f439d34 --- /dev/null +++ b/backend/src/controllers/audio.controller.ts @@ -0,0 +1,119 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { execFile } from 'child_process'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; +import { writeFile, unlink } from 'fs'; +import { raspiService } from '../services/raspi.service.js'; + +const execFileAsync = promisify(execFile); +const writeFileAsync = promisify(writeFile); +const unlinkAsync = promisify(unlink); + +const router = Router(); + +const upload = multer({ storage: multer.memoryStorage() }); + +router.get('/incoming', async (_req, res) => { + try { + const result = await raspiService.listIncomingAudio(); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `List incoming failed: ${message}` }); + } +}); + +router.post('/lock/:filename', async (req, res) => { + try { + const { filename } = req.params; + const result = await raspiService.lockAudio({ filename }); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Lock audio failed: ${message}` }); + } +}); + +router.post('/unlock/:filename', async (req, res) => { + try { + const { filename } = req.params; + const result = await raspiService.unlockAudio({ filename }); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Unlock audio failed: ${message}` }); + } +}); + +router.post('/cancel/:filename', async (req, res) => { + try { + const { filename } = req.params; + const result = await raspiService.cancelAudio({ filename }); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Cancel audio failed: ${message}` }); + } +}); + +router.post('/process/:filename', async (req, res) => { + try { + const { filename } = req.params; + const result = await raspiService.processAudio({ filename }); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Process audio failed: ${message}` }); + } +}); + +const whisperModel = process.env.WHISPER_MODEL ?? 'base'; +const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca'; + +router.post('/upload', upload.single('file'), async (req, res) => { + let tmpFile: string | undefined; + try { + if (!req.file) { + return res.status(400).json({ error: 'No audio file provided' }); + } + + const ext = req.file.originalname.split('.').pop()?.toLowerCase() || 'wav'; + tmpFile = join(tmpdir(), `quibot-audio-${Date.now()}.${ext}`); + await writeFileAsync(tmpFile, req.file.buffer); + + console.log(`[whisper] Model: ${whisperModel}, Language: ${whisperLanguage}, File: ${tmpFile}`); + + const { stdout, stderr } = await execFileAsync('whisper', [ + tmpFile, + '--model', whisperModel, + '--language', whisperLanguage, + '--output_format', 'txt', + ], { maxBuffer: 50 * 1024 * 1024 }); + + if (stderr) { + console.log(`[whisper] stderr: ${stderr}`); + } + + const transcription = stdout.trim(); + + res.json({ + transcription, + originalFilename: req.file.originalname, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Audio transcription failed: ${message}` }); + } finally { + if (tmpFile) { + try { + await unlinkAsync(tmpFile); + } catch { + // ignore cleanup errors + } + } + } +}); + +export default router; diff --git a/backend/src/controllers/command.controller.ts b/backend/src/controllers/command.controller.ts new file mode 100644 index 0000000..e50a81b --- /dev/null +++ b/backend/src/controllers/command.controller.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { raspiService } from '../services/raspi.service.js'; + +const router = Router(); + +router.post('/', async (req, res) => { + const { task } = req.body; + if (!task) { + return res.status(400).json({ error: 'Task name is required' }); + } + try { + const result = await raspiService.runTask({ task }); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Run task failed: ${message}` }); + } +}); + +export default router; diff --git a/backend/src/controllers/motor.controller.ts b/backend/src/controllers/motor.controller.ts new file mode 100644 index 0000000..a1e44d0 --- /dev/null +++ b/backend/src/controllers/motor.controller.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { raspiService } from '../services/raspi.service.js'; + +const router = Router(); + +// Multer config - store in memory for proxying to RasPi +const upload = multer({ storage: multer.memoryStorage() }); + +router.post('/step/forward', async (_req, res) => { + try { + const result = await raspiService.motorStepForward(); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Motor step forward failed: ${message}` }); + } +}); + +router.post('/step/backward', async (_req, res) => { + try { + const result = await raspiService.motorStepBackward(); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Motor step backward failed: ${message}` }); + } +}); + +router.post('/stop', async (_req, res) => { + try { + const result = await raspiService.motorStop(); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Motor stop failed: ${message}` }); + } +}); + +router.post('/upload', upload.single('file'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No audio file provided' }); + } + const result = await raspiService.uploadAudio(req.file.buffer, req.file.originalname); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error'; + res.status(500).json({ error: `Audio upload failed: ${message}` }); + } +}); + +export default router; diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts new file mode 100644 index 0000000..244275a --- /dev/null +++ b/backend/src/controllers/settings.controller.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { getConfig, setRaspberryHost, setRaspberryPort, setToken } from '../config.js'; + +const router = Router(); + +router.get('/', (_req, res) => { + const settings = getConfig(); + res.json(settings); +}); + +router.put('/', (req, res) => { + const { raspberryPi, token } = req.body; + if (raspberryPi?.host !== undefined) { + setRaspberryHost(raspberryPi.host); + } + if (raspberryPi?.port !== undefined) { + setRaspberryPort(Number(raspberryPi.port)); + } + if (token !== undefined) { + setToken(token); + } + res.json(getConfig()); +}); + +export default router; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e806080 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,25 @@ +import express from 'express'; +import cors from 'cors'; +import router from './routes/router.js'; +import { getAppPort, getConfig } from './config.js'; + +const app = express(); + +app.use(cors()); +app.use(express.json()); + +// Handle multipart in motor controller separately +app.use('/audio', express.json()); +app.use('/motor', express.json()); +app.use('/commands', express.json()); + +app.use(router); + +app.get('/health', (_req, res) => { + const settings = getConfig(); + res.json({ status: 'ok', settings }); +}); + +app.listen(getAppPort(), () => { + console.log(`QuiBot backend listening on port ${getAppPort()}`); +}); diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts new file mode 100644 index 0000000..8fe454a --- /dev/null +++ b/backend/src/routes/router.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import motorController from '../controllers/motor.controller.js'; +import audioController from '../controllers/audio.controller.js'; +import commandController from '../controllers/command.controller.js'; +import settingsController from '../controllers/settings.controller.js'; + +const router = Router(); + +router.use('/motor', motorController); +router.use('/audio', audioController); +router.use('/commands', commandController); +router.use('/settings', settingsController); + +export default router; diff --git a/backend/src/services/raspi.service.ts b/backend/src/services/raspi.service.ts new file mode 100644 index 0000000..4a3a6be --- /dev/null +++ b/backend/src/services/raspi.service.ts @@ -0,0 +1,77 @@ +import axios from 'axios'; +import { getRaspberryHost, getRaspberryPort, getToken } from '../config.js'; + +interface RunTaskParams { + task: string; +} + +interface AudioLockParams { + filename: string; +} + +export const raspiService = { + async runTask(params: RunTaskParams) { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/run`, null, { + params: { task: params.task, token: getToken() }, + }); + return res.data; + }, + + async motorStepForward() { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/forward`, null, { + params: { token: getToken() }, + }); + return res.data; + }, + + async motorStepBackward() { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/step/backwards`, null, { + params: { token: getToken() }, + }); + return res.data; + }, + + async motorStop() { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/motor/stop`, null, { + params: { token: getToken() }, + }); + return res.data; + }, + + async uploadAudio(buffer: Buffer, filename?: string) { + const fname = filename || 'audio.wav'; + const ext = fname.split('.').pop()?.toLowerCase() || 'wav'; + const formData = new FormData(); + formData.append('file', new Blob([buffer]), fname); + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/upload`, formData, { + params: { format: ext }, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res.data; + }, + + async listIncomingAudio() { + const res = await axios.get(`${getRaspberryHost()}:${getRaspberryPort()}/audio/incoming`); + return res.data; + }, + + async lockAudio(params: AudioLockParams) { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/lock/${params.filename}`); + return res.data; + }, + + async unlockAudio(params: AudioLockParams) { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/unlock/${params.filename}`); + return res.data; + }, + + async cancelAudio(params: AudioLockParams) { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/cancel/${params.filename}`); + return res.data; + }, + + async processAudio(params: AudioLockParams) { + const res = await axios.post(`${getRaspberryHost()}:${getRaspberryPort()}/audio/process/${params.filename}`); + return res.data; + }, +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..156b6d5 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/igualtat_h3.png b/igualtat_h3.png new file mode 100644 index 0000000..391ff85 Binary files /dev/null and b/igualtat_h3.png differ diff --git a/logo-qui-bot-capcalera.png b/logo-qui-bot-capcalera.png new file mode 100644 index 0000000..88ec92c Binary files /dev/null and b/logo-qui-bot-capcalera.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c622f7f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,389 @@ +{ + "name": "quibot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "vue-i18n": "^11.4.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@intlify/core-base": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.5.tgz", + "integrity": "sha512-lja3F/iKVIvTa48mIwmrIeDcQUFZ0F0drvFvT8AwINOvbwnAzl/S/p8p2DxILZpWEUHRi1qewfWNIkMvhD3kKA==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.4.5", + "@intlify/message-compiler": "11.4.5", + "@intlify/shared": "11.4.5" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.5.tgz", + "integrity": "sha512-W5vydP9Yq3t82IyWqCM6aR0BTWCZrN5RAwjZEPpH8I2OQWp2RLy03Evh2ANZlSMhcvGAoyDg25k0so85Kwncpw==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.5", + "@intlify/shared": "11.4.5" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.5.tgz", + "integrity": "sha512-IEOZiHtbQopyPc/Dz2M869lOlZYX1SdcniNJwphATDYHhovvIneEKf1EFF37DE7NAABZtza1FNtnwwqZWInfpw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.4.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.5.tgz", + "integrity": "sha512-g/i5mtdUa9ia/8BaJ4w6ZRHgAXYQd9XyCaQPRMvsd8d5qmZwkjoTmHrNsI28Q/7I8h+2ijUkI4uEnnMCziKupQ==", + "license": "MIT", + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT", + "peer": true + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT", + "peer": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT", + "peer": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC", + "peer": true + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.5.tgz", + "integrity": "sha512-rm8YJ6RpjOrkcgS2GLrZwLvs/VbhxbTSuEspbyXDo233+fPK0OMFNLOj3fdQYVKdOgcpSfLW91JhbqgpkkcBWA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.5", + "@intlify/devtools-types": "11.4.5", + "@intlify/shared": "11.4.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..724da91 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "vue-i18n": "^11.4.5" + } +} diff --git a/quibot-web/README.md b/quibot-web/README.md new file mode 100644 index 0000000..9051ac2 --- /dev/null +++ b/quibot-web/README.md @@ -0,0 +1,67 @@ +# Quibot Web + +Interfície web per controlar el robit de la Quibot. Construida amb [Nuxt 3](https://nuxt.com/) i [Vue 3](https://vuejs.org/). + +## Requisits previs + +- [Node.js](https://nodejs.org/) (versió 20 o superior) +- npm, yarn o pnpm + +## Instal·lació + +```bash +npm install +``` + +## Execució en mode desenvolupament + +```bash +npm run dev +``` + +L'aplicació estarà disponible a `http://localhost:3000`. + +## Configuració + +L'aplicació es configura mitjançant variables d'entorn: + +| Variable | Descripció | Valor per defecte | +|-------------------|----------------------------------|------------------------| +| `QUIBOT_BASE_URL` | URL del backend del Quibot | `http://quibot:8000` | +| `QUIBOT_TOKEN` | Token d'autenticació del backend | `MY_SECRET_TOKEN` | + +## Comandos disponibles + +| Comando | Descripció | +|-----------------|--------------------------------------------| +| `npm run dev` | Inicia el servidor de desenvolupament | +| `npm run build` | Compila l'aplicació per a producció | +| `npm run generate` | Genera l'aplicació estàtica | +| `npm run preview` | Previsualitza la versió de producció | + +## Funcionalitat + +L'interfície permet enviar comandes al motor del Quibot: + +- **Step Forward** – Mou el motor cap endavant +- **Step Backwards** – Mou el motor cap enrere +- **Stop** – Atura el motor + +## Estructura del projecte + +``` +quibot-web/ +├── app/ +│ └── app.vue # Pàgina principal amb els controls del motor +├── server/ +│ └── api/motor/ # Rutes API del servidor +│ ├── stop.post.ts # Comanda d'aturada +│ └── step/[direction].post.ts # Comandes de moviment +├── public/ +│ └── favicon.ico # Icona del navegador +├── nuxt.config.ts # Configuració del Nuxt +├── package.json +└── tsconfig.json # Configuració de TypeScript +``` + +## Llicència diff --git a/quibot-web/app/app.vue b/quibot-web/app/app.vue index 1ba1929..3137ce9 100644 --- a/quibot-web/app/app.vue +++ b/quibot-web/app/app.vue @@ -1,120 +1,1409 @@ diff --git a/quibot-web/app/locales/ca.ts b/quibot-web/app/locales/ca.ts new file mode 100644 index 0000000..5461016 --- /dev/null +++ b/quibot-web/app/locales/ca.ts @@ -0,0 +1,149 @@ +export default { + header: { + subtitle: 'Monitor de Control del Robot', + online: 'EN LÍNIA', + offline: 'DESCONNECTAT', + }, + panels: { + blockQueue: 'Cua de Blocs', + motionControls: 'Controls de Moviment', + eyeControls: 'Controls dels Ulls', + gestureSensor: 'Sensor de Gestos', + }, + blocks: { + empty: 'No hi ha blocs a la cua', + clear: 'Buidar Cua', + queued: 'A l\'espera', + processing: 'Procesant', + }, + blockActions: { + rd: 'Avançar fins al creuament', + gn: 'Girar a la dreta 90\u00B0', + bu: 'Girar a l\'esquerra 90\u00B0', + ye: 'Agafar / Extrair líquid', + og: 'Deixar / Dispensar líquid', + vt: 'Pausa sorpresa', + }, + colors: { + red: 'Vermell', + green: 'Verd', + blue: 'Blau', + yellow: 'Groc', + orange: 'Taronja', + violet: 'Violeta', + }, + motion: { + forward: 'Endavant', + backward: 'Enrere', + left: 'Esquerra', + right: 'Dreta', + sending: 'Enviant: {dir}', + sent: 'Comanda de motor enviada: {dir}', + failed: 'La comanda ha fallat', + }, + motionToast: { + success: 'Moviment {dir}', + }, + eyes: { + shapeLabel: 'Color dels Ulls', + actionsLabel: 'Accions', + applyShape: 'Aplicar Forma', + applyColor: 'Aplicar Color', + eyeOn: 'Encendre', + eyeOff: 'Apagar', + toastSetShape: 'Forma dels ulls establerta: {shape}', + toastFailedShape: 'No s\'ha pogut establir la forma dels ulls', + toastSetColor: 'Color dels ulls establert: {color}', + toastFailedColor: 'No s\'ha pogut establir el color dels ulls', + toastOn: 'Ulls encesos', + toastOff: 'Ulls apagats', + toastFailedOn: 'No s\'han pogut encendre els ulls', + toastFailedOff: 'No s\'han pogut apagar els ulls', + }, + eyeShapes: { + open: 'Oberts', + fw: 'Endavant', + down: 'Baix', + gesture: 'Gest', + }, + eyeColors: { + white: 'Blanc', + red: 'Vermell', + green: 'Verd', + blue: 'Blau', + yellow: 'Groc', + orange: 'Taronja', + purple: 'Lila', + cyan: 'Cian', + black: 'Apagat', + }, + gestures: { + modeLabel: 'Mode de Funcionament', + blockMode: 'Mode Blocs', + gestureMode: 'Mode Gestos', + detectedLabel: 'Gestos Detectats', + empty: 'Encara no s\'han detectat gestos', + reference: 'Referència de Gestos', + clearLog: 'Buidar Registre', + toggleToast: 'Canviar mode', + }, + gestureNames: { + forward: 'Empènyer Endavant', + left: 'Ona Esquerra', + right: 'Ona Dreta', + up: 'Ona Amunt', + down: 'Ona Avall', + clockwise: 'Cercle CW', + anticlockwise: 'Cercle CCW', + wave: 'Ona (Commute)', + }, + gestureActions: { + forward: 'Avançar creuament', + right: 'Girar a la dreta 90\u00B0', + left: 'Girar a l\'esquerra 90\u00B0', + up: 'Agafar / Extreure', + down: 'Deixar / Dispensar', + clockwise: 'Inactiu', + anticlockwise: 'Inactiu', + wave: 'Canviar mode', + }, + gestureRef: { + forward: 'Empènyer Endavant', + left: 'Ona Esquerra', + right: 'Ona Dreta', + up: 'Ona Amunt', + down: 'Ona Avall', + clockwise: 'Cercle CW', + anticlockwise: 'Cercle CCW', + wave: 'Ona (Ambdues)', + }, + modes: { + block: 'Mode Blocs', + gesture: 'Mode Gestos', + }, + toast: { + cleared: 'Cua buidada', + switched: 'Canviat a mode {mode}', + }, + theme: { + light: 'Canviar a mode clar', + dark: 'Canviar a mode fosc', + }, + settings: { + title: 'Configuració', + save: 'Desar', + saved: 'Configuració desada', + theme: { + label: 'Tema', + dark: 'Fosc', + light: 'Clar', + }, + language: { + label: 'Idioma', + }, + piUrl: { + label: 'URL del Raspberry Pi', + placeholder: 'http://raspberrypi.local:8000', + }, + }, +} diff --git a/quibot-web/app/locales/en.ts b/quibot-web/app/locales/en.ts new file mode 100644 index 0000000..405fcfc --- /dev/null +++ b/quibot-web/app/locales/en.ts @@ -0,0 +1,149 @@ +export default { + header: { + subtitle: 'Robot Control Monitor', + online: 'ONLINE', + offline: 'OFFLINE', + }, + panels: { + blockQueue: 'Block Queue', + motionControls: 'Motion Controls', + eyeControls: 'Eye Controls', + gestureSensor: 'Gesture Sensor', + }, + blocks: { + empty: 'No blocks in queue', + clear: 'Clear Queue', + queued: 'Queued', + processing: 'Processing', + }, + blockActions: { + rd: 'Advance to crossing', + gn: 'Turn right 90\u00B0', + bu: 'Turn left 90\u00B0', + ye: 'Take / Extract liquid', + og: 'Leave / Dispense liquid', + vt: 'Surprise pause', + }, + colors: { + red: 'Red', + green: 'Green', + blue: 'Blue', + yellow: 'Yellow', + orange: 'Orange', + violet: 'Violet', + }, + motion: { + forward: 'Forward', + backward: 'Back', + left: 'Left', + right: 'Right', + sending: 'Sending: {dir}', + sent: 'Motor command sent: {dir}', + failed: 'Command failed', + }, + motionToast: { + success: 'Motion {dir}', + }, + eyes: { + shapeLabel: 'Eye Color', + actionsLabel: 'Actions', + applyShape: 'Apply Shape', + applyColor: 'Apply Color', + eyeOn: 'Turn On', + eyeOff: 'Turn Off', + toastSetShape: 'Eye shape set: {shape}', + toastFailedShape: 'Failed to set eye shape', + toastSetColor: 'Eye color set: {color}', + toastFailedColor: 'Failed to set eye color', + toastOn: 'Eyes turned on', + toastOff: 'Eyes turned off', + toastFailedOn: 'Failed to turn eyes on', + toastFailedOff: 'Failed to turn eyes off', + }, + eyeShapes: { + open: 'Open', + fw: 'Forward', + down: 'Down', + gesture: 'Gesture', + }, + eyeColors: { + white: 'White', + red: 'Red', + green: 'Green', + blue: 'Blue', + yellow: 'Yellow', + orange: 'Orange', + purple: 'Purple', + cyan: 'Cyan', + black: 'Off', + }, + gestures: { + modeLabel: 'Operating Mode', + blockMode: 'Block Mode', + gestureMode: 'Gesture Mode', + detectedLabel: 'Detected Gestures', + empty: 'No gestures detected yet', + reference: 'Gesture Reference', + clearLog: 'Clear Log', + toggleToast: 'Toggle mode', + }, + gestureNames: { + forward: 'Push Forward', + left: 'Wave Left', + right: 'Wave Right', + up: 'Wave Up', + down: 'Wave Down', + clockwise: 'Circle CW', + anticlockwise: 'Circle CCW', + wave: 'Wave (Toggle)', + }, + gestureActions: { + forward: 'Advance crossing', + right: 'Turn right 90\u00B0', + left: 'Turn left 90\u00B0', + up: 'Take / Extract', + down: 'Leave / Dispense', + clockwise: 'Idle', + anticlockwise: 'Idle', + wave: 'Toggle mode', + }, + gestureRef: { + forward: 'Push Forward', + left: 'Wave Left', + right: 'Wave Right', + up: 'Wave Up', + down: 'Wave Down', + clockwise: 'Circle CW', + anticlockwise: 'Circle CCW', + wave: 'Wave (Both)', + }, + modes: { + block: 'Block Mode', + gesture: 'Gesture Mode', + }, + toast: { + cleared: 'Queue cleared', + switched: 'Switched to {mode} mode', + }, + theme: { + light: 'Switch to light mode', + dark: 'Switch to dark mode', + }, + settings: { + title: 'Settings', + save: 'Save', + saved: 'Settings saved', + theme: { + label: 'Theme', + dark: 'Dark', + light: 'Light', + }, + language: { + label: 'Language', + }, + piUrl: { + label: 'Raspberry Pi URL', + placeholder: 'http://raspberrypi.local:8000', + }, + }, +} diff --git a/quibot-web/app/locales/es.ts b/quibot-web/app/locales/es.ts new file mode 100644 index 0000000..1852db9 --- /dev/null +++ b/quibot-web/app/locales/es.ts @@ -0,0 +1,149 @@ +export default { + header: { + subtitle: 'Monitor de Control del Robot', + online: 'EN LÍNEA', + offline: 'DESCONNECTADO', + }, + panels: { + blockQueue: 'Cola de Bloques', + motionControls: 'Controles de Movimiento', + eyeControls: 'Controles de los Ojos', + gestureSensor: 'Sensor de Gestos', + }, + blocks: { + empty: 'No hay bloques en la cola', + clear: 'Borrar Cola', + queued: 'En espera', + processing: 'Procesando', + }, + blockActions: { + rd: 'Avanzar hasta el cruce', + gn: 'Girar a la derecha 90\u00B0', + bu: 'Girar a la izquierda 90\u00B0', + ye: 'Tomar / Extraer líquido', + og: 'Dejar / Dispensar líquido', + vt: 'Pausa sorpresa', + }, + colors: { + red: 'Rojo', + green: 'Verde', + blue: 'Azul', + yellow: 'Amarillo', + orange: 'Naranja', + violet: 'Violeta', + }, + motion: { + forward: 'Adelante', + backward: 'Atrás', + left: 'Izquierda', + right: 'Derecha', + sending: 'Enviando: {dir}', + sent: 'Comando de motor enviado: {dir}', + failed: 'El comando falló', + }, + motionToast: { + success: 'Movimiento {dir}', + }, + eyes: { + shapeLabel: 'Color de los Ojos', + actionsLabel: 'Acciones', + applyShape: 'Aplicar Forma', + applyColor: 'Aplicar Color', + eyeOn: 'Encender', + eyeOff: 'Apagar', + toastSetShape: 'Forma de ojos establecida: {shape}', + toastFailedShape: 'No se pudo establecer la forma de los ojos', + toastSetColor: 'Color de ojos establecido: {color}', + toastFailedColor: 'No se pudo establecer el color de los ojos', + toastOn: 'Ojos encendidos', + toastOff: 'Ojos apagados', + toastFailedOn: 'No se pudieron encender los ojos', + toastFailedOff: 'No se pudieron apagar los ojos', + }, + eyeShapes: { + open: 'Abiertos', + fw: 'Adelante', + down: 'Abajo', + gesture: 'Gesto', + }, + eyeColors: { + white: 'Blanco', + red: 'Rojo', + green: 'Verde', + blue: 'Azul', + yellow: 'Amarillo', + orange: 'Naranja', + purple: 'Morado', + cyan: 'Cian', + black: 'Apagado', + }, + gestures: { + modeLabel: 'Modo de Funcionamiento', + blockMode: 'Modo Bloques', + gestureMode: 'Modo Gestos', + detectedLabel: 'Gestos Detectados', + empty: 'Aún no se han detectado gestos', + reference: 'Referencia de Gestos', + clearLog: 'Borrar Registro', + toggleToast: 'Cambiar modo', + }, + gestureNames: { + forward: 'Empujar Adelante', + left: 'Onda Izquierda', + right: 'Onda Derecha', + up: 'Onda Arriba', + down: 'Onda Abajo', + clockwise: 'Círculo CW', + anticlockwise: 'Círculo CCW', + wave: 'Onda (Conmute)', + }, + gestureActions: { + forward: 'Avanzar cruce', + right: 'Girar a la derecha 90\u00B0', + left: 'Girar a la izquierda 90\u00B0', + up: 'Tomar / Extraer', + down: 'Dejar / Dispensar', + clockwise: 'Inactivo', + anticlockwise: 'Inactivo', + wave: 'Cambiar modo', + }, + gestureRef: { + forward: 'Empujar Adelante', + left: 'Onda Izquierda', + right: 'Onda Derecha', + up: 'Onda Arriba', + down: 'Onda Abajo', + clockwise: 'Círculo CW', + anticlockwise: 'Círculo CCW', + wave: 'Onda (Ambas)', + }, + modes: { + block: 'Modo Bloques', + gesture: 'Modo Gestos', + }, + toast: { + cleared: 'Cola borrada', + switched: 'Cambiado a modo {mode}', + }, + theme: { + light: 'Cambiar a modo claro', + dark: 'Cambiar a modo oscuro', + }, + settings: { + title: 'Configuración', + save: 'Guardar', + saved: 'Configuración guardada', + theme: { + label: 'Tema', + dark: 'Oscuro', + light: 'Claro', + }, + language: { + label: 'Idioma', + }, + piUrl: { + label: 'URL de Raspberry Pi', + placeholder: 'http://raspberrypi.local:8000', + }, + }, +} diff --git a/quibot-web/app/plugins/i18n.ts b/quibot-web/app/plugins/i18n.ts new file mode 100644 index 0000000..f8a88cf --- /dev/null +++ b/quibot-web/app/plugins/i18n.ts @@ -0,0 +1,38 @@ +import { defineNuxtPlugin, useRuntimeConfig } from '#app' +import { createI18n } from 'vue-i18n' +import en from '~/locales/en' +import ca from '~/locales/ca' +import es from '~/locales/es' + +const locales = ['en', 'ca', 'es'] as const +type LocaleType = (typeof locales)[number] + +function getSavedLocale(): LocaleType { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('quibot-locale') + if (saved && locales.includes(saved as LocaleType)) { + return saved as LocaleType + } + } + return 'en' +} + +export default defineNuxtPlugin((nuxtApp) => { + const locale = getSavedLocale() + const i18n = createI18n({ + legacy: false, + locale, + fallbackLocale: 'en', + messages: { en, ca, es }, + }) + + nuxtApp.vueApp.use(i18n) + + // Expose setLocale for global access + nuxtApp.provide('setLocale', (l: LocaleType) => { + if (locales.includes(l)) { + i18n.global.locale.value = l + localStorage.setItem('quibot-locale', l) + } + }) +}) diff --git a/quibot-web/components/SettingsModal.vue b/quibot-web/components/SettingsModal.vue new file mode 100644 index 0000000..6e850bd --- /dev/null +++ b/quibot-web/components/SettingsModal.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/quibot-web/nuxt.config.ts b/quibot-web/nuxt.config.ts index e3376af..2ad7d8e 100644 --- a/quibot-web/nuxt.config.ts +++ b/quibot-web/nuxt.config.ts @@ -5,6 +5,10 @@ export default defineNuxtConfig({ runtimeConfig: { quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000', quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN', + public: { + defaultLocale: 'en', + supportedLocales: ['en', 'ca', 'es'], + } }, vite: { optimizeDeps: { diff --git a/quibot-web/package-lock.json b/quibot-web/package-lock.json index 8842776..4e05915 100644 --- a/quibot-web/package-lock.json +++ b/quibot-web/package-lock.json @@ -8,10 +8,10 @@ "hasInstallScript": true, "dependencies": { "nuxt": "^4.4.2", + "react-native-svg": "^15.15.5", "vue": "^3.5.32", "vue-router": "^5.0.4" - }, - "devDependencies": {} + } }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -365,6 +365,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1024,6 +1034,47 @@ "node": ">=18.0.0" } }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2928,6 +2979,441 @@ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "license": "MIT" }, + "node_modules/@react-native/assets-registry": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz", + "integrity": "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.0.tgz", + "integrity": "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/codegen/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@react-native/codegen/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@react-native/codegen/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/codegen/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/codegen/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/codegen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@react-native/codegen/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@react-native/codegen/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.0.tgz", + "integrity": "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/dev-middleware": "0.86.0", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.86.0" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.0.tgz", + "integrity": "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.0.tgz", + "integrity": "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w==", + "license": "MIT", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.0.tgz", + "integrity": "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.0", + "@react-native/debugger-shell": "0.86.0", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "peer": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/dev-middleware/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.0.tgz", + "integrity": "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz", + "integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.0.tgz", + "integrity": "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.0.tgz", + "integrity": "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.86.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.13", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", @@ -3487,6 +3973,13 @@ "@simple-git/args-pathspec": "^1.0.3" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT", + "peer": true + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -3533,12 +4026,66 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT", + "peer": true + }, "node_modules/@unhead/vue": { "version": "2.1.13", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.13.tgz", @@ -3870,6 +4417,20 @@ "node": ">=6.5" } }, + "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", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3900,6 +4461,13 @@ "node": ">= 14" } }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT", + "peer": true + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -4079,6 +4647,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT", + "peer": true + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -4173,6 +4748,16 @@ } } }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-parser": "0.36.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4386,6 +4971,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -4477,6 +5072,19 @@ "node": ">=8" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -4509,6 +5117,52 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4533,6 +5187,130 @@ "node": ">=18" } }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chrome-launcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chrome-launcher/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "peer": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/chromium-edge-launcher/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chromium-edge-launcher/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "peer": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chromium-edge-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "peer": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT", + "peer": true + }, "node_modules/citty": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", @@ -4626,6 +5404,39 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -5051,6 +5862,17 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5220,6 +6042,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -5383,6 +6215,13 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -5456,6 +6295,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "peer": true, + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5491,6 +6353,82 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT", + "peer": true + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -5736,6 +6674,16 @@ "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -5748,6 +6696,30 @@ "node": ">= 0.4" } }, + "node_modules/hermes-compiler": { + "version": "250829098.0.14", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz", + "integrity": "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==", + "license": "MIT", + "peer": true + }, + "node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT", + "peer": true + }, + "node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.36.0" + } + }, "node_modules/hookable": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", @@ -5847,6 +6819,22 @@ "integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==", "license": "MIT" }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "peer": true, + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/impound": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz", @@ -5875,6 +6863,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/ioredis": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", @@ -6107,6 +7105,113 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6122,6 +7227,13 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD", + "peer": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6222,6 +7334,44 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "peer": true + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -6305,12 +7455,32 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "license": "MIT" }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT", + "peer": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6394,12 +7564,36 @@ "source-map-js": "^1.2.1" } }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT", + "peer": true + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6415,6 +7609,497 @@ "node": ">= 8" } }, + "node_modules/metro": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", + "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", + "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT", + "peer": true + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro-cache": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", + "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", + "license": "MIT", + "peer": true, + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache-key": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", + "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-config": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", + "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.4", + "metro-cache": "0.84.4", + "metro-core": "0.84.4", + "metro-runtime": "0.84.4", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-core": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", + "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-file-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", + "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", + "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-resolver": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", + "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-runtime": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", + "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", + "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.4", + "nullthrows": "^1.1.1", + "ob1": "0.84.4", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", + "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.4", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-symbolicate/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", + "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", + "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-source-map": "0.84.4", + "metro-transform-plugins": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/metro/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/metro/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/metro/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT", + "peer": true + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/metro/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/metro/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/metro/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/metro/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6528,6 +8213,19 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "peer": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -6608,6 +8306,16 @@ "integrity": "sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==", "license": "MIT" }, + "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", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nitropack": { "version": "2.13.3", "resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.3.tgz", @@ -6759,6 +8467,13 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT", + "peer": true + }, "node_modules/node-mock-http": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", @@ -6834,6 +8549,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT", + "peer": true + }, "node_modules/nuxt": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz", @@ -6934,6 +8656,19 @@ "node": ">=18" } }, + "node_modules/ob1": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", + "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", + "license": "MIT", + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -7717,6 +9452,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -7732,6 +9495,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "peer": true, + "dependencies": { + "asap": "~2.0.6" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -7748,6 +9521,16 @@ ], "license": "MIT" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "peer": true, + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7793,6 +9576,323 @@ "destr": "^2.0.5" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "peer": true + }, + "node_modules/react-native": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", + "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/assets-registry": "0.86.0", + "@react-native/codegen": "0.86.0", + "@react-native/community-cli-plugin": "0.86.0", + "@react-native/gradle-plugin": "0.86.0", + "@react-native/js-polyfills": "0.86.0", + "@react-native/normalize-colors": "0.86.0", + "@react-native/virtualized-lists": "0.86.0", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.14", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native/jest-preset": "0.86.0", + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-svg": { + "version": "15.15.5", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", + "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/react-native-svg/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/react-native-svg/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-native/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "peer": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/react-native/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-native/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-native/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-native/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -7879,6 +9979,13 @@ "node": ">=4" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "peer": true + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -7888,6 +9995,16 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -8072,6 +10189,13 @@ "node": ">=11.0.0" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -8116,6 +10240,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/serialize-javascript": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", @@ -8320,6 +10454,36 @@ "node": ">=20.16.0" } }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT", + "peer": true + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -8658,6 +10822,13 @@ "b4a": "^1.6.4" } }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT", + "peer": true + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8698,6 +10869,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8816,6 +10994,13 @@ "node": ">=18.12.0" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT", + "peer": true + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -8883,6 +11068,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", @@ -9138,6 +11333,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -9496,6 +11701,13 @@ "@types/estree": "^1.0.0" } }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT", + "peer": true + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -9583,6 +11795,16 @@ } } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -9595,6 +11817,13 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT", + "peer": true + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/quibot-web/package.json b/quibot-web/package.json index c8f4d07..8fa0d5a 100644 --- a/quibot-web/package.json +++ b/quibot-web/package.json @@ -11,9 +11,8 @@ }, "dependencies": { "nuxt": "^4.4.2", + "react-native-svg": "^15.15.5", "vue": "^3.5.32", "vue-router": "^5.0.4" - }, - "devDependencies": { } } diff --git a/quibot-web/server/api/motor/step/[direction].post.ts b/quibot-web/server/api/motor/step/[direction].post.ts index a2a9302..c0f9ce4 100644 --- a/quibot-web/server/api/motor/step/[direction].post.ts +++ b/quibot-web/server/api/motor/step/[direction].post.ts @@ -1,5 +1,4 @@ export default defineEventHandler(async (event) => { - const config = useRuntimeConfig() const direction = getRouterParam(event, 'direction') if (direction !== 'forward' && direction !== 'backwards') { @@ -9,7 +8,10 @@ export default defineEventHandler(async (event) => { }) } - return await $fetch(`${config.quibotBaseUrl}/motor/step/${direction}`, { + const baseUrl = getPiBaseUrl(event) + const config = useRuntimeConfig() + + return await $fetch(`${baseUrl}/motor/step/${direction}`, { method: 'POST', query: { token: config.quibotToken, diff --git a/quibot-web/server/api/motor/stop.post.ts b/quibot-web/server/api/motor/stop.post.ts index 6e787b4..9564b7a 100644 --- a/quibot-web/server/api/motor/stop.post.ts +++ b/quibot-web/server/api/motor/stop.post.ts @@ -1,7 +1,8 @@ -export default defineEventHandler(async () => { +export default defineEventHandler(async (event) => { + const baseUrl = getPiBaseUrl(event) const config = useRuntimeConfig() - return await $fetch(`${config.quibotBaseUrl}/motor/stop`, { + return await $fetch(`${baseUrl}/motor/stop`, { method: 'POST', query: { token: config.quibotToken, diff --git a/quibot-web/server/plugins/settings.ts b/quibot-web/server/plugins/settings.ts new file mode 100644 index 0000000..447d542 --- /dev/null +++ b/quibot-web/server/plugins/settings.ts @@ -0,0 +1,10 @@ +export default defineNitroPlugin((nitroApp) => { + const base = useRuntimeConfig().quibotBaseUrl + nitroApp.hooks.hook('request', (event) => { + const url = getCookie(event, 'quibot-pi-url') + if (url && url !== decodeURIComponent(base)) { + // Override the base URL for this request + (event.context as any).__piBaseUrl = url + } + }) +}) diff --git a/quibot-web/server/utils/pi-url.ts b/quibot-web/server/utils/pi-url.ts new file mode 100644 index 0000000..fc51693 --- /dev/null +++ b/quibot-web/server/utils/pi-url.ts @@ -0,0 +1,9 @@ +import { getCookie } from 'h3' + +export function getPiBaseUrl(event: any): string { + const cookieUrl = getCookie(event, 'quibot-pi-url') + if (cookieUrl) return decodeURIComponent(cookieUrl) + + const config = useRuntimeConfig() + return config.quibotBaseUrl +} diff --git a/raspi/.gitignore b/raspi/.gitignore new file mode 100644 index 0000000..0540009 --- /dev/null +++ b/raspi/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +venv/ \ No newline at end of file diff --git a/raspi/blocks.py b/raspi/blocks.py new file mode 100644 index 0000000..39ed457 --- /dev/null +++ b/raspi/blocks.py @@ -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 diff --git a/raspi/eyes.py b/raspi/eyes.py new file mode 100644 index 0000000..5a534d0 --- /dev/null +++ b/raspi/eyes.py @@ -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) diff --git a/raspi/gesture.py b/raspi/gesture.py new file mode 100644 index 0000000..04bd6e6 --- /dev/null +++ b/raspi/gesture.py @@ -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 diff --git a/backend/main.py b/raspi/main.py similarity index 100% rename from backend/main.py rename to raspi/main.py diff --git a/raspi/motion.py b/raspi/motion.py new file mode 100644 index 0000000..6dbd37b --- /dev/null +++ b/raspi/motion.py @@ -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) diff --git a/raspi/pins.py b/raspi/pins.py new file mode 100644 index 0000000..dca6307 --- /dev/null +++ b/raspi/pins.py @@ -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 diff --git a/raspi/quibot.py b/raspi/quibot.py new file mode 100644 index 0000000..c696159 --- /dev/null +++ b/raspi/quibot.py @@ -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() diff --git a/raspi/tests/test_blocks.py b/raspi/tests/test_blocks.py new file mode 100644 index 0000000..3ffe773 --- /dev/null +++ b/raspi/tests/test_blocks.py @@ -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 diff --git a/raspi/tests/test_eyes.py b/raspi/tests/test_eyes.py new file mode 100644 index 0000000..3b13d04 --- /dev/null +++ b/raspi/tests/test_eyes.py @@ -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() diff --git a/raspi/tests/test_gesture.py b/raspi/tests/test_gesture.py new file mode 100644 index 0000000..e10bf97 --- /dev/null +++ b/raspi/tests/test_gesture.py @@ -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() diff --git a/raspi/tests/test_motion.py b/raspi/tests/test_motion.py new file mode 100644 index 0000000..470364b --- /dev/null +++ b/raspi/tests/test_motion.py @@ -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() diff --git a/raspi/tests/test_simple_motor.py b/raspi/tests/test_simple_motor.py new file mode 100644 index 0000000..fb7ec06 --- /dev/null +++ b/raspi/tests/test_simple_motor.py @@ -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()