From 973a1ce431551fa728d5309f76145b96f9262a89 Mon Sep 17 00:00:00 2001 From: ordinarthur Date: Fri, 27 Mar 2026 10:40:24 +0100 Subject: [PATCH] add ws & simulator --- apps/simulator/.gitignore | 24 + apps/simulator/index.html | 16 + apps/simulator/package.json | 23 + apps/simulator/src/App.tsx | 262 +++++++++++ apps/simulator/src/hooks/useMicrophone.ts | 60 +++ apps/simulator/src/hooks/useSocket.ts | 91 ++++ apps/simulator/src/main.tsx | 9 + apps/simulator/tsconfig.json | 26 ++ apps/simulator/vite.config.ts | 9 + docs/hardware.md | 348 ++++++++++---- package.json | 1 + pnpm-lock.yaml | 526 ++++++++++++++++++++++ 12 files changed, 1307 insertions(+), 88 deletions(-) create mode 100644 apps/simulator/.gitignore create mode 100644 apps/simulator/index.html create mode 100644 apps/simulator/package.json create mode 100644 apps/simulator/src/App.tsx create mode 100644 apps/simulator/src/hooks/useMicrophone.ts create mode 100644 apps/simulator/src/hooks/useSocket.ts create mode 100644 apps/simulator/src/main.tsx create mode 100644 apps/simulator/tsconfig.json create mode 100644 apps/simulator/vite.config.ts diff --git a/apps/simulator/.gitignore b/apps/simulator/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/simulator/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/simulator/index.html b/apps/simulator/index.html new file mode 100644 index 0000000..0bbf501 --- /dev/null +++ b/apps/simulator/index.html @@ -0,0 +1,16 @@ + + + + + + Ti-Pote Simulator + + + +
+ + + diff --git a/apps/simulator/package.json b/apps/simulator/package.json new file mode 100644 index 0000000..f0f9415 --- /dev/null +++ b/apps/simulator/package.json @@ -0,0 +1,23 @@ +{ + "name": "simulator", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "~5.9.3", + "vite": "^8.0.1" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4", + "socket.io-client": "^4.8.3" + } +} diff --git a/apps/simulator/src/App.tsx b/apps/simulator/src/App.tsx new file mode 100644 index 0000000..7749c67 --- /dev/null +++ b/apps/simulator/src/App.tsx @@ -0,0 +1,262 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useSocket, type RobotState } from './hooks/useSocket'; +import { useMicrophone } from './hooks/useMicrophone'; + +const STATE_COLORS: Record = { + disconnected: '#666', + idle: '#888', + listening: '#2196f3', + thinking: '#ff9800', + speaking: '#4caf50', +}; + +const STATE_LABELS: Record = { + disconnected: 'Déconnecté', + idle: 'Veille', + listening: 'Écoute...', + thinking: 'Réflexion...', + speaking: 'Parle...', +}; + +function App() { + const [serverUrl, setServerUrl] = useState('http://localhost:3000'); + const [deviceToken, setDeviceToken] = useState(''); + const { state, connected, logs, connect, disconnect, emit, clearLogs } = useSocket(); + const logsEndRef = useRef(null); + + const onAudioChunk = useCallback( + (chunk: ArrayBuffer, sampleRate: number) => { + emit('audio_chunk', { data: chunk, sampleRate }); + }, + [emit], + ); + + const onSpeechEnd = useCallback(() => { + emit('speech_end'); + }, [emit]); + + const { recording, start: startMic, stop: stopMic } = useMicrophone({ onAudioChunk, onSpeechEnd }); + + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + + const handleConnect = () => { + if (connected) { + disconnect(); + } else { + if (!deviceToken.trim()) return alert('Device token requis'); + connect(serverUrl, deviceToken.trim()); + } + }; + + const handleWakeWord = () => { + emit('wake_word_detected'); + }; + + const handleInterrupt = () => { + emit('user_interrupt'); + if (recording) stopMic(); + }; + + return ( +
+

Ti-Pote Simulator

+ + {/* Connection panel */} +
+

Connexion

+
+ + setServerUrl(e.target.value)} + disabled={connected} + /> +
+
+ + setDeviceToken(e.target.value)} + disabled={connected} + placeholder="JWT du device" + /> +
+ +
+ + {/* Status */} +
+

État du robot

+
+
+ {STATE_LABELS[state]} +
+
+ + {/* Controls */} +
+

Contrôles

+
+ + + +
+
+ + {/* Logs */} +
+
+

Logs

+ +
+
+ {logs.map((log, i) => ( +
+ {log.timestamp.toLocaleTimeString()} + + {log.direction === 'in' ? '◀' : log.direction === 'out' ? '▶' : '●'} + + {log.event} + {log.data && {log.data}} +
+ ))} +
+
+
+
+ ); +} + +const styles: Record = { + container: { + maxWidth: 640, + margin: '0 auto', + padding: 24, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + color: '#e0e0e0', + background: '#1a1a2e', + minHeight: '100vh', + }, + title: { + fontSize: 24, + fontWeight: 700, + marginBottom: 20, + color: '#fff', + }, + panel: { + background: '#16213e', + borderRadius: 8, + padding: 16, + marginBottom: 16, + }, + panelTitle: { + fontSize: 14, + fontWeight: 600, + textTransform: 'uppercase' as const, + letterSpacing: 1, + color: '#888', + margin: '0 0 12px 0', + }, + field: { + marginBottom: 10, + }, + label: { + display: 'block', + fontSize: 12, + color: '#aaa', + marginBottom: 4, + }, + input: { + width: '100%', + padding: '8px 12px', + border: '1px solid #333', + borderRadius: 4, + background: '#0f3460', + color: '#fff', + fontSize: 14, + boxSizing: 'border-box' as const, + }, + btn: { + padding: '8px 20px', + border: 'none', + borderRadius: 4, + color: '#fff', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + }, + btnRow: { + display: 'flex', + gap: 8, + }, + statusRow: { + display: 'flex', + alignItems: 'center', + gap: 12, + }, + statusDot: { + width: 16, + height: 16, + borderRadius: '50%', + transition: 'all 0.3s', + }, + statusLabel: { + fontSize: 18, + fontWeight: 600, + }, + logHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + logContainer: { + height: 250, + overflowY: 'auto' as const, + fontFamily: 'monospace', + fontSize: 12, + background: '#0a0a1a', + borderRadius: 4, + padding: 8, + }, + logEntry: { + display: 'flex', + gap: 8, + padding: '2px 0', + borderBottom: '1px solid #1a1a2e', + }, + logTime: { color: '#666', flexShrink: 0 }, + logDirection: { flexShrink: 0 }, + logEvent: { color: '#fff', fontWeight: 600, flexShrink: 0 }, + logData: { color: '#aaa', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }, +}; + +export default App; diff --git a/apps/simulator/src/hooks/useMicrophone.ts b/apps/simulator/src/hooks/useMicrophone.ts new file mode 100644 index 0000000..c98dc21 --- /dev/null +++ b/apps/simulator/src/hooks/useMicrophone.ts @@ -0,0 +1,60 @@ +import { useRef, useState, useCallback } from 'react'; + +interface UseMicrophoneOptions { + onAudioChunk: (chunk: ArrayBuffer, sampleRate: number) => void; + onSpeechEnd: () => void; +} + +export function useMicrophone({ onAudioChunk, onSpeechEnd }: UseMicrophoneOptions) { + const [recording, setRecording] = useState(false); + const streamRef = useRef(null); + const contextRef = useRef(null); + const processorRef = useRef(null); + + const start = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true }, + }); + streamRef.current = stream; + + const context = new AudioContext({ sampleRate: 16000 }); + contextRef.current = context; + + const source = context.createMediaStreamSource(stream); + // 4096 samples per chunk at 16kHz = ~256ms per chunk + const processor = context.createScriptProcessor(4096, 1, 1); + processorRef.current = processor; + + processor.onaudioprocess = (e) => { + const float32 = e.inputBuffer.getChannelData(0); + // Convert Float32 to Int16 PCM + const int16 = new Int16Array(float32.length); + for (let i = 0; i < float32.length; i++) { + const s = Math.max(-1, Math.min(1, float32[i])); + int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; + } + onAudioChunk(int16.buffer, context.sampleRate); + }; + + source.connect(processor); + processor.connect(context.destination); + setRecording(true); + } catch (err) { + console.error('Microphone access denied:', err); + } + }, [onAudioChunk]); + + const stop = useCallback(() => { + processorRef.current?.disconnect(); + contextRef.current?.close(); + streamRef.current?.getTracks().forEach((t) => t.stop()); + processorRef.current = null; + contextRef.current = null; + streamRef.current = null; + setRecording(false); + onSpeechEnd(); + }, [onSpeechEnd]); + + return { recording, start, stop }; +} diff --git a/apps/simulator/src/hooks/useSocket.ts b/apps/simulator/src/hooks/useSocket.ts new file mode 100644 index 0000000..3b7b74b --- /dev/null +++ b/apps/simulator/src/hooks/useSocket.ts @@ -0,0 +1,91 @@ +import { useRef, useState, useCallback } from 'react'; +import { io, Socket } from 'socket.io-client'; + +export type RobotState = 'disconnected' | 'idle' | 'listening' | 'thinking' | 'speaking'; + +export interface LogEntry { + timestamp: Date; + direction: 'in' | 'out' | 'system'; + event: string; + data?: string; +} + +export function useSocket() { + const socketRef = useRef(null); + const [state, setState] = useState('disconnected'); + const [connected, setConnected] = useState(false); + const [logs, setLogs] = useState([]); + + const addLog = useCallback((direction: LogEntry['direction'], event: string, data?: string) => { + setLogs((prev) => [...prev.slice(-200), { timestamp: new Date(), direction, event, data }]); + }, []); + + const connect = useCallback( + (serverUrl: string, deviceToken: string) => { + if (socketRef.current) { + socketRef.current.disconnect(); + } + + const url = serverUrl.replace(/\/$/, ''); + const socket = io(`${url}/ws/robot`, { + auth: { token: deviceToken }, + transports: ['websocket'], + }); + + socket.on('connect', () => { + setConnected(true); + addLog('system', 'connected', `Socket ID: ${socket.id}`); + }); + + socket.on('disconnect', (reason) => { + setConnected(false); + setState('disconnected'); + addLog('system', 'disconnected', reason); + }); + + socket.on('connect_error', (err) => { + addLog('system', 'connect_error', err.message); + }); + + socket.on('status', (payload: { state: RobotState }) => { + setState(payload.state); + addLog('in', 'status', payload.state); + }); + + socket.on('audio_chunk', (payload: { data: ArrayBuffer }) => { + addLog('in', 'audio_chunk', `${payload.data?.byteLength ?? 0} bytes`); + // TODO: play audio through speakers + }); + + socket.on('notification', (payload: Record) => { + addLog('in', 'notification', JSON.stringify(payload)); + }); + + socket.on('response_start', () => addLog('in', 'response_start')); + socket.on('response_end', () => addLog('in', 'response_end')); + + socketRef.current = socket; + }, + [addLog], + ); + + const disconnect = useCallback(() => { + socketRef.current?.disconnect(); + socketRef.current = null; + setConnected(false); + setState('disconnected'); + }, []); + + const emit = useCallback( + (event: string, data?: unknown) => { + if (!socketRef.current?.connected) return; + socketRef.current.emit(event, data); + addLog('out', event, data ? JSON.stringify(data).slice(0, 100) : undefined); + }, + [addLog], + ); + + const clearLogs = useCallback(() => setLogs([]), []); + + return { state, connected, logs, connect, disconnect, emit, clearLogs }; +} diff --git a/apps/simulator/src/main.tsx b/apps/simulator/src/main.tsx new file mode 100644 index 0000000..e17d50b --- /dev/null +++ b/apps/simulator/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/simulator/tsconfig.json b/apps/simulator/tsconfig.json new file mode 100644 index 0000000..721fcfe --- /dev/null +++ b/apps/simulator/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + "jsx": "react-jsx", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/apps/simulator/vite.config.ts b/apps/simulator/vite.config.ts new file mode 100644 index 0000000..5c59447 --- /dev/null +++ b/apps/simulator/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); diff --git a/docs/hardware.md b/docs/hardware.md index 6b557ba..b8b2ece 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -1,68 +1,224 @@ -# Propositions Hardware +# Architecture Hardware ## Philosophie Le robot Ti-Pote est un **client léger**. Il ne fait aucun traitement lourd d'IA. Son rôle est de capter l'environnement (son, image), d'envoyer les données au cloud, et de restituer les réponses (audio, écran, mouvement). Le choix du hardware est guidé par ce principe : fiable, basse consommation, suffisant pour le streaming et le wake word local. -## Configuration de base (desk robot) +## Architecture dual-chip -### Carte principale : Raspberry Pi 5 (4 Go) +Ti-Pote utilise une architecture à **deux processeurs couplés**, chacun spécialisé dans son domaine. Ce pattern classique en robotique embarquée sépare le "cerveau" (orchestration, réseau) des "réflexes" (audio temps réel, actionneurs). + +``` + ┌─────────────────────────────────┐ + │ RASPBERRY PI ZERO 2 W │ + │ (Cerveau / Client) │ + │ │ + │ • Client WebSocket vers cloud │ + │ • Wake word (OpenWakeWord) │ + │ • Orchestration des commandes │ + │ • Caméra (CSI) │ + │ • OTA updates │ + │ • Logs / diagnostics │ + │ │ + └───────────┬───────────────────────┘ + │ + UART (TX/RX/GND) + 921600 baud + │ + ┌───────────▼───────────────────────┐ + │ ESP32-S3 │ + │ (Réflexes / Temps réel) │ + │ │ + │ • Capture audio I2S (micro MEMS) │ + │ • Sortie audio I2S (ampli) │ + │ • Contrôle PWM des servos │ + │ • LEDs (NeoPixel WS2812B) │ + │ • Capteurs (touch, PIR...) │ + │ • Mode idle autonome │ + │ │ + └───────────────────────────────────┘ +``` + +### Pourquoi deux chips ? + +**Raspberry Pi Zero 2 W** — Tourne sous Linux (Raspberry Pi OS Lite). Permet de coder le client en TypeScript/Node.js, cohérent avec le reste du stack. Gère la connexion WebSocket, le protocole applicatif, et le wake word via OpenWakeWord (Python). Pas de contrainte temps réel ici : les latences réseau dominent. + +**ESP32-S3** — Microcontrôleur temps réel, pas d'OS. Gère nativement l'audio I2S (micro + speaker), le PWM pour les servos, et les LEDs — le tout avec des latences de l'ordre de la microseconde. Pas besoin de stack TCP/WebSocket complexe côté firmware : il reçoit des commandes simples du Pi et les exécute. + +### Avantages de cette architecture + +- **Séparation des responsabilités** : le Pi n'a pas à gérer l'audio ni les servos en temps réel, ses 512 Mo et son CPU suffisent largement. L'ESP32 n'a pas à gérer le réseau ni le protocole applicatif. +- **Résilience** : si le Pi redémarre (OTA, crash), l'ESP32 peut maintenir un mode "idle animation" autonome (respiration des LEDs, position neutre des servos). +- **Découpage du travail** : Juliann peut développer le firmware ESP32 en Arduino/C++ pendant qu'Arthur développe le client Pi en TypeScript, avec une interface série bien définie entre les deux. +- **Coût** : l'ESP32-S3 ajoute ~5€ et un PCB de 2×3 cm — négligeable dans un boîtier imprimé 3D. + +## Communication inter-chips + +### Liaison physique + +La communication entre le Pi Zero 2 W et l'ESP32-S3 se fait en **UART série** : + +| Paramètre | Valeur | +|-----------|--------| +| Protocole | UART | +| Baud rate | 921600 | +| Fils | 3 (TX, RX, GND) | +| Débit effectif | ~90 Ko/s | +| Latence | < 1 ms | + +> **Note :** SPI est envisageable si le débit UART s'avère insuffisant (ex: streaming vidéo depuis l'ESP32). SPI offre plusieurs Mo/s mais nécessite plus de fils (MOSI, MISO, CLK, CS) et un protocole master/slave plus complexe. + +### Protocole de frames + +Chaque message échangé entre les deux chips suit un format binaire simple : + +``` +┌────────┬──────┬──────────┬──────────┬─────────────┬──────┐ +│ START │ TYPE │ LENGTH_H │ LENGTH_L │ PAYLOAD │ CRC8 │ +│ 0xAA │ 1B │ 1B │ 1B │ 0-65535 B │ 1B │ +└────────┴──────┴──────────┴──────────┴─────────────┴──────┘ +``` + +| Champ | Taille | Description | +|-------|--------|-------------| +| START | 1 octet | Octet de synchronisation : `0xAA` | +| TYPE | 1 octet | Type de message (voir table ci-dessous) | +| LENGTH | 2 octets | Taille du payload (big-endian) | +| PAYLOAD | 0-65535 octets | Données du message | +| CRC8 | 1 octet | Checksum CRC-8 sur TYPE + LENGTH + PAYLOAD | + +### Types de messages + +| Code | Nom | Direction | Description | +|------|-----|-----------|-------------| +| `0x01` | `AUDIO_UP` | ESP32 → Pi | Chunk audio capturé par le micro (PCM 16kHz 16bit mono) | +| `0x02` | `AUDIO_DOWN` | Pi → ESP32 | Chunk audio TTS à jouer sur le speaker | +| `0x03` | `SERVO_CMD` | Pi → ESP32 | Commande servo : `{servo_id: u8, angle: u16, speed: u16}` | +| `0x04` | `LED_CMD` | Pi → ESP32 | Commande LED : `{mode: u8, r: u8, g: u8, b: u8, duration: u16}` | +| `0x05` | `STATUS` | Bidirectionnel | Heartbeat et état : `{state: u8, battery: u8, temp: u8}` | +| `0x06` | `SENSOR_DATA` | ESP32 → Pi | Données capteurs (touch, PIR, température...) | +| `0x07` | `CONFIG` | Pi → ESP32 | Paramètres runtime (volume, sensibilité micro, servo trim...) | +| `0x08` | `ACK` | Bidirectionnel | Acquittement pour les commandes critiques | +| `0x09` | `IDLE_MODE` | Pi → ESP32 | Active/désactive le mode idle autonome | + +### Flux audio détaillé + +#### Uplink : utilisateur parle → cloud + +``` +Micro MEMS (INMP441) + │ + │ I2S (16kHz, 16bit, mono) + ▼ +ESP32-S3 Pi Zero 2 W Cloud +┌──────────────┐ UART ┌──────────────┐ WebSocket ┌──────────┐ +│ Buffer │ ────────►│ Réception │ ────────────►│ STT │ +│ circulaire │ AUDIO_UP │ frames │ audio PCM │ (Deepgram│ +│ DMA → UART │ │ → WebSocket │ │ /Whisper)│ +└──────────────┘ └──────────────┘ └──────────┘ +``` + +L'ESP32-S3 capture l'audio via I2S depuis un micro MEMS (INMP441). Les samples sont accumulés dans un buffer circulaire DMA puis envoyés au Pi toutes les ~20ms sous forme de frames `AUDIO_UP`. Le Pi reçoit ces frames, extrait le payload PCM, et le pousse dans le WebSocket vers le backend NestJS qui le forward au service STT. + +**Débit audio :** 16000 Hz × 16 bits × 1 canal = 32 Ko/s. Avec l'overhead du protocole (~5 octets par frame), on reste bien en dessous des 90 Ko/s du UART à 921600 baud. + +#### Downlink : cloud répond → speaker + +``` +Cloud Pi Zero 2 W ESP32-S3 +┌──────────┐ WebSocket ┌──────────────┐ UART ┌──────────────┐ +│ TTS │ ────────────►│ Réception │ ────────────►│ Buffer I2S │ +│(Eleven- │ audio PCM │ WebSocket │ AUDIO_DOWN │ DMA → DAC │ +│ Labs) │ │ → frames │ │ → Ampli │ +└──────────┘ └──────────────┘ │ → Speaker │ + └──────────────┘ +``` + +Le backend envoie les chunks audio TTS via WebSocket au Pi. Le Pi les encapsule en frames `AUDIO_DOWN` et les transmet à l'ESP32 via UART. L'ESP32 les injecte dans son buffer I2S en sortie, connecté à un ampli MAX98357A, puis au speaker. L'ESP32 gère le timing audio hardware — pas de glitch ni de jitter. + +### Mode idle autonome + +Quand le Pi est indisponible (redémarrage, OTA, perte réseau), l'ESP32 bascule en mode idle après un timeout configurable (défaut : 5 secondes sans heartbeat). En mode idle : + +- Les LEDs passent en respiration lente (bleu/blanc) +- Les servos reviennent en position neutre avec un mouvement fluide +- L'ESP32 continue de monitorer les capteurs +- Dès que le Pi renvoie un heartbeat `STATUS`, l'ESP32 quitte le mode idle + +## Composants détaillés + +### Carte principale : Raspberry Pi Zero 2 W | Spec | Détail | |------|--------| -| CPU | Broadcom BCM2712, quad-core Cortex-A76 @ 2.4GHz | -| RAM | 4 Go LPDDR4X | -| Connectivité | Wi-Fi 802.11ac dual-band, Bluetooth 5.0, Gigabit Ethernet | -| GPIO | 40 pins pour les modules (moteurs, LEDs, etc.) | -| Prix | ~60€ | +| CPU | Broadcom BCM2710A1, quad-core Cortex-A53 @ 1GHz | +| RAM | 512 Mo LPDDR2 | +| Connectivité | Wi-Fi 802.11 b/g/n, Bluetooth 4.2 BLE | +| GPIO | 40 pins (UART, SPI, I2C, CSI) | +| Prix | ~15€ | +| Consommation | ~0.5W idle, ~1.5W en charge | -Pourquoi le Pi 5 : suffisamment puissant pour gérer le streaming audio/vidéo WebSocket, le wake word local (OpenWakeWord), et les animations LED, tout en restant abordable et bien documenté. La communauté est immense, ce qui facilite le debug. +Le Pi Zero 2 W est suffisant pour ce rôle : il ne gère ni l'audio ni les servos, seulement la connexion WebSocket (faible bande passante), le wake word (OpenWakeWord tourne en CPU sur ARM), et le relai de commandes vers l'ESP32. Linux permet de coder le client en TypeScript/Node.js. -Alternative : Raspberry Pi 4 (4 Go) — moins cher (~45€), suffisant si on n'a pas besoin du surplus de puissance du Pi 5. +### Contrôleur temps réel : ESP32-S3 -### Microphone : ReSpeaker 2-Mics Pi HAT ou ReSpeaker 4-Mic Array +| Spec | Détail | +|------|--------| +| CPU | Xtensa LX7, dual-core @ 240MHz | +| RAM | 512 Ko SRAM + 8 Mo PSRAM (selon module) | +| Périphériques | I2S ×2, UART ×3, SPI ×4, PWM, ADC, GPIO | +| Prix | ~5€ (module ESP32-S3-WROOM-1) | +| Consommation | ~0.1W idle, ~0.5W en charge | -**Option A — ReSpeaker 2-Mics Pi HAT (~12€)** -- 2 microphones, suffisant pour un robot de bureau -- Se branche directement sur le header GPIO du Pi -- Inclut des LEDs RGB programmables (utile pour le feedback visuel) -- Simple à installer (driver I2S) +Le S3 (et non le S2 ou le classique) est recommandé pour son double cœur (un pour l'audio I2S, un pour le UART + servos) et son support USB natif (utile pour le flash/debug). -**Option B — ReSpeaker 4-Mic Array (~30€)** -- 4 microphones en array circulaire -- Meilleure captation directionnelle (beamforming) -- Meilleure suppression du bruit ambiant -- Recommandé si le robot est dans un environnement bruyant +### Microphone : INMP441 (micro MEMS I2S) -**Recommandation** : commencer avec le 2-Mics pour le MVP. Passer au 4-Mic si la qualité audio est insuffisante. +| Spec | Détail | +|------|--------| +| Interface | I2S directe avec l'ESP32-S3 | +| Sample rate | Jusqu'à 48kHz (on utilise 16kHz) | +| SNR | 61 dB | +| Prix | ~3€ le module | + +Avantage par rapport au ReSpeaker HAT : connexion I2S directe sur l'ESP32, pas besoin de HAT ni de driver Linux. Plusieurs INMP441 peuvent être combinés pour du beamforming basique si nécessaire. + +### Amplificateur audio : MAX98357A (I2S) + +| Spec | Détail | +|------|--------| +| Interface | I2S directe avec l'ESP32-S3 | +| Puissance | 3.2W @ 4Ω | +| Prix | ~4€ le breakout board | + +Connecté directement à l'ESP32 en I2S. Pas de DAC externe nécessaire — le MAX98357A intègre le DAC et l'ampli. Sortie directe sur un petit speaker 3W. ### Haut-parleur **Option A — Mini haut-parleur 3W (~5€)** - Petit, s'intègre dans le boîtier imprimé 3D - Qualité suffisante pour la parole -- Connecté via le DAC du ReSpeaker ou un ampli I2S (MAX98357A) +- Connecté directement au MAX98357A **Option B — Haut-parleur 5W avec amplificateur (~15€)** - Meilleur volume et qualité audio -- Nécessite un petit ampli (MAX98357A breakout board) - Recommandé pour un usage dans une pièce plus grande ### Caméra (module optionnel) **Raspberry Pi Camera Module 3 (~30€)** - 12 MP, autofocus -- Connecteur CSI direct sur le Pi +- Connecteur CSI direct sur le Pi Zero 2 W - Bon pour la reconnaissance visuelle et l'OCR - Faible latence de capture -Alternative : Raspberry Pi Camera Module 3 Wide (~35€) — champ de vision plus large (120°), utile si le robot doit "voir" une grande partie de la pièce. +Alternative : Raspberry Pi Camera Module 3 Wide (~35€) — champ de vision plus large (120°). ### Écran (module optionnel) **Option A — Écran OLED 1.3" SH1106 (~8€)** - Petit écran pour afficher des émotions (yeux), l'heure, des icônes de statut -- Interface I2C, très simple à contrôler +- Interface I2C — peut être connecté soit au Pi, soit à l'ESP32 - Basse consommation - Parfait pour un robot de bureau compact @@ -73,63 +229,61 @@ Alternative : Raspberry Pi Camera Module 3 Wide (~35€) — champ de vision plu **Option C — Écran tactile 5" HDMI (~40€)** - Écran complet avec tactile -- Connecté en HDMI, fonctionne comme un display Linux -- Peut afficher l'interface web de Ti-Pote directement +- Connecté en mini-HDMI sur le Pi Zero 2 W - Le plus polyvalent mais aussi le plus encombrant et énergivore +### Servos + +Les servos pour l'animation (tête, bras, paupières...) sont connectés directement aux pins PWM de l'ESP32-S3. L'ESP32 gère le timing PWM hardware — mouvements fluides garantis sans jitter, contrairement à un contrôle depuis Linux (softPWM). + +- Micro servos SG90 (~3€ pièce) pour les petits mouvements (paupières, oreilles) +- Servos MG90S (~5€ pièce) pour les axes principaux (rotation tête, inclinaison) + ### Alimentation **Alimentation secteur (configuration desk)** -- Alimentation USB-C 5V 5A officielle Raspberry Pi (~15€) +- Alimentation USB-C 5V 3A (~10€) — suffisante pour le Pi Zero 2 W + ESP32 + servos - Le robot desk est branché en permanence **Module batterie (optionnel, pour la mobilité)** -- PiSugar 3 (~40€) — batterie intégrée pour Raspberry Pi, avec circuit de charge -- UPS HAT avec 18650 (~25€) — plus de capacité, rechargeable -- Autonomie estimée : 2-4h selon l'usage +- PiSugar 2 (~30€) — batterie intégrée pour Pi Zero, avec circuit de charge +- Autonomie estimée : 3-6h grâce à la faible consommation du Pi Zero 2 W + +### LEDs et feedback visuel + +Les LEDs sont pilotées par l'ESP32-S3 (temps réel, animations fluides) : + +| État | Animation LED | +|------|--------------| +| Écoute | Bleue pulsante | +| Réflexion | Jaune clignotante | +| Parle | Verte | +| Erreur | Rouge | +| Veille | Blanche très faible | +| Mode idle (Pi indisponible) | Bleu/blanc respiration lente | + +Options : anneau NeoPixel WS2812B 12 LEDs (~8€) connecté au GPIO de l'ESP32. ## Module base mobile (optionnel) -Pour rendre Ti-Pote mobile, il faut ajouter : +Pour rendre Ti-Pote mobile, on réutilise l'ESP32-S3 existant (il a assez de pins et de puissance) ou on ajoute un second ESP32 dédié aux moteurs : ### Moteurs et châssis -- 2x moteurs DC avec encodeur (~15€ le kit) — pour la traction différentielle -- Driver moteur L298N ou DRV8833 (~5€) +- 2x moteurs DC avec encodeur (~15€ le kit) — traction différentielle +- Driver moteur DRV8833 (~5€) - Roue omnidirectionnelle arrière (bille) pour la stabilité - Le châssis est imprimé en 3D (design par Juliann) -### Contrôleur moteur +### Contrôle moteur -Option recommandée : une carte Arduino Nano ou ESP32 dédiée au contrôle moteur, qui communique avec le Pi via série (UART). Avantage : le Pi envoie des commandes haut niveau ("avance 30cm", "tourne 90°") et l'Arduino gère le PID et les encodeurs en temps réel. - -``` -┌─────────────┐ UART/I2C ┌──────────────┐ -│ Raspberry Pi │ ◄────────────► │ Arduino Nano │ -│ (cerveau) │ │ (moteurs) │ -│ │ │ - PID control│ -│ │ │ - Encodeurs │ -│ │ │ - Capteurs │ -└─────────────┘ └──────────────┘ -``` +Si l'ESP32-S3 principal a assez de GPIO disponibles, il peut gérer les moteurs en plus de l'audio et des servos (le dual-core le permet). Sinon, un second ESP32 ou Arduino Nano dédié au contrôle moteur communique avec le premier ESP32 en I2C. ### Capteurs de navigation (optionnels) -- Capteurs ultrason HC-SR04 (~3€ x3) — détection d'obstacles basique +- Capteurs ultrason HC-SR04 (~3€ x3) — détection d'obstacles - Capteur infrarouge TCRT5000 (~2€ x2) — détection de bord de table -- IMU MPU6050 (~5€) — accéléromètre + gyroscope pour l'orientation - -## LEDs et feedback visuel - -Les LEDs sont essentielles pour communiquer l'état du robot sans audio : - -- **Écoute** : LED bleue pulsante (comme Alexa) -- **Réflexion** : LED jaune clignotante -- **Parle** : LED verte -- **Erreur** : LED rouge -- **Veille** : LED blanche très faible - -Options : LEDs RGB intégrées au ReSpeaker, ou un anneau NeoPixel WS2812B (~8€) pour plus de flexibilité et d'effets visuels. +- IMU MPU6050 (~5€) — accéléromètre + gyroscope ## BOM estimé (Bill of Materials) @@ -137,45 +291,47 @@ Options : LEDs RGB intégrées au ReSpeaker, ou un anneau NeoPixel WS2812B (~8 | Composant | Prix estimé | |-----------|------------| -| Raspberry Pi 5 (4Go) | 60€ | -| ReSpeaker 2-Mics HAT | 12€ | +| Raspberry Pi Zero 2 W | 15€ | +| ESP32-S3-WROOM-1 (module) | 5€ | +| INMP441 micro MEMS I2S | 3€ | +| MAX98357A ampli I2S | 4€ | | Mini haut-parleur 3W | 5€ | | Carte microSD 32Go | 8€ | -| Alimentation USB-C 5V 5A | 15€ | +| Alimentation USB-C 5V 3A | 10€ | | Filament PLA (boîtier) | ~5€ | -| Visserie, câbles | ~5€ | -| **Total** | **~110€** | +| Visserie, câbles, PCB | ~5€ | +| **Total** | **~60€** | ### Configuration complète (desk, voix + caméra + écran) | Composant | Prix estimé | |-----------|------------| -| Configuration minimale | 110€ | +| Configuration minimale | 60€ | | Pi Camera Module 3 | 30€ | | Écran OLED 1.3" | 8€ | | NeoPixel Ring (12 LEDs) | 8€ | -| **Total** | **~156€** | +| 2x micro servos SG90 | 6€ | +| **Total** | **~112€** | ### Configuration mobile | Composant | Prix estimé | |-----------|------------| -| Configuration complète | 156€ | +| Configuration complète | 112€ | | 2x moteurs DC + encodeurs | 15€ | | Driver moteur DRV8833 | 5€ | -| Arduino Nano | 8€ | -| Batterie PiSugar 3 | 40€ | +| Batterie PiSugar 2 | 30€ | | Capteurs ultrason x3 | 9€ | -| **Total** | **~233€** | +| **Total** | **~171€** | ## Wake word — détail technique -Le wake word est la seule tâche d'IA qui tourne localement sur le robot. Deux options : +Le wake word est la seule tâche d'IA qui tourne localement, sur le **Pi Zero 2 W**. L'ESP32 streame l'audio capturé au Pi en continu ; le Pi fait tourner OpenWakeWord en parallèle sur le flux audio entrant. ### OpenWakeWord (recommandé pour le MVP) - Open source, gratuit -- Tourne sur CPU (Pi 5 sans problème) +- Tourne sur CPU (le quad-core Cortex-A53 du Pi Zero 2 W est suffisant) - Modèles pré-entraînés disponibles - Possibilité d'entraîner un modèle custom pour "Hey Ti-Pote" - Latence : <200ms @@ -186,22 +342,38 @@ Le wake word est la seule tâche d'IA qui tourne localement sur le robot. Deux o - Solution commerciale, plan gratuit limité - Très optimisé, ultra basse latence (<100ms) - Console web pour créer des wake words custom -- SDK disponible en Python et C - Payant si plus de 3 devices en production -**Recommandation** : OpenWakeWord pour le MVP (gratuit, suffisant). Entraîner un modèle custom "Hey Ti-Pote" avec leur outil de fine-tuning. Garder Porcupine comme alternative si la précision d'OpenWakeWord est insuffisante. +**Recommandation** : OpenWakeWord pour le MVP. Entraîner un modèle custom "Hey Ti-Pote". Garder Porcupine comme alternative si la précision est insuffisante. -## Firmware du robot +## Firmware -Le firmware du robot tourne sur le Pi et gère : +### Côté Pi Zero 2 W (TypeScript/Node.js) -1. **Connexion WebSocket** permanente avec le core -2. **Wake word detection** en continu (OpenWakeWord) -3. **Capture et streaming audio** (via ALSA/PulseAudio + le micro) -4. **Playback audio** (réception et lecture des chunks TTS) -5. **Capture image** (à la demande, via la caméra) -6. **Contrôle des LEDs** (feedback visuel de l'état) -7. **Communication avec l'Arduino** (si module mobile, via UART) -8. **Mode offline dégradé** (timers locaux, commandes basiques) +Le client tourne sous Linux (Raspberry Pi OS Lite) et gère : -Le firmware sera écrit en **Python** (pour la compatibilité avec OpenWakeWord et les librairies audio du Pi) ou en **TypeScript/Node.js** (pour la cohérence avec le reste du projet). À discuter avec Juliann en fonction de ses préférences côté hardware. +1. **Connexion WebSocket** permanente avec le backend NestJS +2. **Réception des frames UART** depuis l'ESP32 (audio up, sensor data) +3. **Envoi des frames UART** vers l'ESP32 (audio down, servo/LED commands) +4. **Wake word detection** via OpenWakeWord (Python, appelé en subprocess ou FFI) +5. **Capture image** à la demande via la caméra CSI +6. **OTA updates** pour lui-même et pour l'ESP32 (flash via UART) +7. **Logs et diagnostics** envoyés au cloud + +### Côté ESP32-S3 (Arduino/C++ ou ESP-IDF) + +Le firmware temps réel gère : + +1. **Capture audio I2S** en continu (buffer DMA circulaire) +2. **Playback audio I2S** (réception des chunks, buffer, sortie DAC) +3. **Contrôle PWM** des servos (positions, vitesses, interpolation) +4. **Contrôle LED** (modes d'animation, transitions fluides) +5. **Lecture des capteurs** (touch, PIR, température) +6. **Parsing des frames UART** (réception des commandes du Pi) +7. **Mode idle autonome** (si perte de heartbeat du Pi) + +### Interface série — contrat entre les deux firmwares + +L'interface UART entre le Pi et l'ESP32 est le **contrat** qui permet aux deux équipes de travailler en parallèle. Tant que les deux côtés respectent le protocole de frames défini plus haut (START + TYPE + LENGTH + PAYLOAD + CRC8), chaque firmware peut évoluer indépendamment. + +Pour le développement, un **simulateur d'ESP32** côté Pi (qui génère des frames UART factices) et un **simulateur de Pi** côté ESP32 (qui envoie des commandes de test via USB-serial) permettent de développer et tester chaque côté isolément. diff --git a/package.json b/package.json index e9b5447..e1c26e5 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Robot animatronique de bureau personnel — modulaire, imprimé en 3D, propulsé par l'IA", "scripts": { "dev": "pnpm --filter @ti-pote/backend dev", + "dev:sim": "pnpm --filter simulator dev", "build": "pnpm --filter @ti-pote/backend build", "start": "pnpm --filter @ti-pote/backend start:prod", "lint": "pnpm -r lint", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79c9c28..54e2e41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,34 @@ importers: specifier: ^5.8.3 version: 5.8.3 + apps/simulator: + dependencies: + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^8.0.1 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0) + packages: '@angular-devkit/core@19.2.17': @@ -821,6 +849,9 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@nestjs/cli@11.0.16': resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} engines: {node: '>= 20.11'} @@ -939,6 +970,9 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -947,6 +981,107 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@sinclair/typebox@0.34.48': resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} @@ -1068,6 +1203,14 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -1254,6 +1397,19 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1689,6 +1845,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -1731,6 +1890,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1785,6 +1948,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + engine.io-client@6.6.4: + resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} + engine.io-parser@5.2.3: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} @@ -2437,6 +2603,80 @@ packages: libphonenumber-js@1.12.40: resolution: {integrity: sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2588,6 +2828,11 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2807,6 +3052,10 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -2863,9 +3112,18 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2912,6 +3170,11 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2928,6 +3191,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} @@ -3003,6 +3269,10 @@ packages: socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + socket.io-parser@4.2.6: resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} engines: {node: '>=10.0.0'} @@ -3011,6 +3281,10 @@ packages: resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} engines: {node: '>=10.2.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -3379,6 +3653,49 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -3454,6 +3771,10 @@ packages: utf-8-validate: optional: true + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4250,6 +4571,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nestjs/cli@11.0.16(@types/node@25.5.0)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) @@ -4403,11 +4731,64 @@ snapshots: dependencies: consola: 3.4.2 + '@oxc-project/types@0.122.0': {} + '@pkgjs/parseargs@0.11.0': optional: true '@pkgr/core@0.2.9': {} + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + '@sinclair/typebox@0.34.48': {} '@sinonjs/commons@3.0.1': @@ -4557,6 +4938,14 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@types/send@1.2.1': dependencies: '@types/node': 25.5.0 @@ -4732,6 +5121,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0) + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -5194,6 +5588,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + dayjs@1.11.20: {} debug@4.4.3: @@ -5220,6 +5616,8 @@ snapshots: depd@2.0.0: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} diff@4.0.4: {} @@ -5258,6 +5656,18 @@ snapshots: encodeurl@2.0.0: {} + engine.io-client@6.6.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + engine.io-parser@5.2.3: {} engine.io@6.6.6: @@ -6188,6 +6598,55 @@ snapshots: libphonenumber-js@1.12.40: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -6306,6 +6765,8 @@ snapshots: mute-stream@2.0.0: {} + nanoid@3.3.11: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -6496,6 +6957,12 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -6542,8 +7009,15 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-is@18.3.1: {} + react@19.2.4: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -6579,6 +7053,27 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rolldown@1.0.0-rc.12: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + router@2.2.0: dependencies: debug: 4.4.3 @@ -6601,6 +7096,8 @@ snapshots: safer-buffer@2.1.2: {} + scheduler@0.27.0: {} + schema-utils@3.3.0: dependencies: '@types/json-schema': 7.0.15 @@ -6709,6 +7206,17 @@ snapshots: - supports-color - utf-8-validate + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.4 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -6730,6 +7238,8 @@ snapshots: - supports-color - utf-8-validate + source-map-js@1.2.1: {} + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -7048,6 +7558,20 @@ snapshots: vary@1.1.2: {} + vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(terser@5.46.1)(tsx@4.21.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + terser: 5.46.1 + tsx: 4.21.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -7142,6 +7666,8 @@ snapshots: ws@8.18.3: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {}