diff --git a/.gitignore b/.gitignore index 98cff96d..c8b10647 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ node_modules/ .pio .vscode/ config.h -robots.json \ No newline at end of file +robots.json +roverd-dummy +server/config.yaml diff --git a/README.md b/README.md index 482ac961..5a9d4c48 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,87 @@ # Multi Roomba Rover a remake of my RoombaRover project with a decentralized and embedded approach +supports multiple roombas on each roomba: -- an esp32 - - a level shifter - - DONT FORGET THE BRC PIN PULSE -- a power supply -- an openIPC camera - - USB wifi card - - microphone - - speaker -- MAYBE a master relay which can be turned off programatically to save the roomba from discharging. based on battery voltage plus urgent battery #? +- a raspberry pi zero 2 w + - a raspberry pi camera +- roomba's serial port hooked up to the built in serial port on the raspberry pi -UDP: -one control stream out to the esp32 -one raw data sensor stream in form the esp32 \ No newline at end of file +pi provisioning: +- enable serial port +- disable wifi powersave +- disable bluetooth + +## Repo layout + +- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets. +- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`. +- `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the SRT video publisher that streams camera feeds to the server). +- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX). + +## Quick start + +```bash +# build the Pi agent (armv7) +cd pi/roverd +mkdir -p ../../dist +make pi-build + +# start the server + UI +cd ../../server +npm install +npm run start +``` + +Need a fake rover or multi-rover testing without hardware? Use the dummy build: + +```bash +cd pi/roverd +mkdir -p ../../dist +make dummy +./../../dist/roverd-dummy -config ./roverd.sample.yaml +``` + +The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as you’d like on your dev machine. + +## Admin config & authentication + +Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UI’s login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks can’t grant extra control. + +Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it installs roverd plus the SRT video publisher service): + +```bash +cd ~/MultiRoombaRover +sudo ./pi/install_roverd.sh +``` + +Then point each rover's `/etc/roverd.yaml` at `ws://:8080/rover`, set `name` to the rover’s ID, and (optionally) override `media.publishUrl` if your control server isn’t `192.168.0.86`. The video publisher service (`video-publisher.service`) captures the Pi camera with `rpicam-vid`/`libcamera-vid`, pipes the raw H264 into the stock FFmpeg binary, and publishes via SRT to the server’s mediaMTX instance at `srt://:9000?streamid=#!::r=,m=publish`. The installer now just pulls `libcamera-apps` + `ffmpeg` from apt (no custom build). Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher service remotely. +Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name. + +## Fedora server deployment + +Run the installer from inside the `server/` directory after cloning the repo onto your Fedora 43 Server box: + +```bash +cd ~/MultiRoombaRover/server +sudo ./install_server.sh +``` + +The script must be executed via `sudo` from the user that owns the repo. It will: + +- install Node.js/npm plus curl/tar +- run `npm install --production` +- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins + `media.whepBaseUrl`) +- download mediaMTX v1.15.3 and drop it into `/usr/local/bin` +- write `/etc/mediamtx/mediamtx.yml` from `server/mediamtx/mediamtx.yml` (SRT ingest on :9000, open ingest, viewer auth webhook at `/mediamtx/auth`) +- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly + +Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically. + +Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (`http://192.168.0.86:8889`). The client automatically requests `http:////whep`, so you never have to embed IDs or path templates. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer. + +### Video handshake + diagnostics + +- Every `video:request` returns `{ url, token }`. The browser posts the SDP offer to `url` and includes `Authorization: Basic base64(token:token)`. mediaMTX forwards the username (`token`) to `/mediamtx/auth`, which checks the socket’s permissions (driver assignment, admin/spectator role, lockdown state) and either returns 200 or 401—no query parameters are involved anymore. +- To see what mediaMTX is ingesting from the Pis, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rover’s `ready` state and byte counters so you can instantly spot publish issues. diff --git a/authentication_and_access_control.md b/authentication_and_access_control.md new file mode 100644 index 00000000..a173a04d --- /dev/null +++ b/authentication_and_access_control.md @@ -0,0 +1,33 @@ +# access control modes: +- open + - open to the public + - users are randomly assigned a rover to drive, with priority on the rover with the least amount of drivers + - if there are multiple people on one rover, those people will all be controlling that rover at the same time +- turns + - open to the public + - users are randomly assigned to a rover same as open mode + - if there are multiple people on one rover, they will each have one minute at a time to drive. + - the turn queue loops + - the rover will stop moving and stop all aux. motors if the turn switches to a different person +- admin + - admin authentication is required to access the driver page at all +- lockdown + - ONLY lockdown admins can access the driver page + - the future spectator page is DISABLED (not even a way to log into it) + +# roles in the access control system: +- user + - default, for normal people who visit the site +- admin + - authentication needed + - can drive and view any rover when not on lockdown, no matter who is controlling it + - can switch modes (including switching to lockdown mode) +- lockdown admin + - authentication needed + - only works if said admin has lockdown: enabled in config + - can ALWAYS access and control EVERYTHING that there is to do on the site + +# other things to keep in mind: +- in the future, there will be a Discord bot for community alerts and a few admin controls + - wherever the admin list is configured, there has to be a spot for their discord ID + - admin's discord IDs are linked to their admin name server-side \ No newline at end of file diff --git a/dist/dummy1.yml b/dist/dummy1.yml new file mode 100644 index 00000000..5b70188b --- /dev/null +++ b/dist/dummy1.yml @@ -0,0 +1,20 @@ +#configuration for roverd +name: dummy1 +serverUrl: ws://192.168.0.84:8080/rover +serial: + device: /dev/ttyS0 + baud: 115200 +brc: + gpioPin: 25 + pulseEvery: 1m + pulseWidth: 1s +battery: + full: 2068 + warn: 1700 + urgent: 1650 +maxWheelSpeed: 350 +media: + manage: false + service: mediamtx.service + healthUrl: http://127.0.0.1:9997/v3/paths/list + healthInterval: 30s diff --git a/dist/dummy2.yml b/dist/dummy2.yml new file mode 100644 index 00000000..7de4a341 --- /dev/null +++ b/dist/dummy2.yml @@ -0,0 +1,20 @@ +#configuration for roverd +name: dummy2 +serverUrl: ws://192.168.0.84:8080/rover +serial: + device: /dev/ttyS0 + baud: 115200 +brc: + gpioPin: 25 + pulseEvery: 1m + pulseWidth: 1s +battery: + full: 2068 + warn: 1700 + urgent: 1650 +maxWheelSpeed: 350 +media: + manage: false + service: mediamtx.service + healthUrl: http://127.0.0.1:9997/v3/paths/list + healthInterval: 30s diff --git a/dist/roverd b/dist/roverd new file mode 100755 index 00000000..7d008d09 Binary files /dev/null and b/dist/roverd differ diff --git a/docs/pi-deployment.md b/docs/pi-deployment.md new file mode 100644 index 00000000..b3ae32e9 --- /dev/null +++ b/docs/pi-deployment.md @@ -0,0 +1,112 @@ +# Pi Deployment Guide + +## Tooling + +Fedora 43: + +```bash +sudo dnf install golang libgpiod +``` + +Cross-compiling roverd for Pi Zero 2 W (ARMv7): + +```bash +cd pi/roverd +mkdir -p ../../dist +make pi-build +``` + +The binary is placed in `dist/roverd` (relative to the repo root). + +### Dummy rover build (for local testing) + +If you need extra “virtual” rovers on your laptop/CI box, build the dummy binary: + +```bash +cd pi/roverd +mkdir -p ../../dist +make dummy +``` + +Run the resulting `dist/roverd-dummy` on any machine; it will connect to the server, stream fake Group 100 sensor data, and log drive commands so you can test multi-rover features without additional hardware. + +## Automated installation (recommended) + +Install the Raspberry Pi camera helpers (Bookworm ships them): + +```bash +sudo apt update +sudo apt install libcamera-apps +``` + +Once the binary (and repo) are on the Pi, run the helper script from the repo root: + +```bash +cd ~/MultiRoombaRover +sudo ./pi/install_roverd.sh +``` + +What the script does: + +- creates the `roverd` service account (dialout/gpio/video groups) if missing and installs `/usr/local/bin/roverd` +- copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched) +- installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists +- installs `/usr/local/bin/video-publisher`, drops `video-publisher.service`, and enables it so video is published automatically on boot + +Flags: + +| Flag | Purpose | +|------|---------| +| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) | +| `-c PATH` | seed `/etc/roverd.yaml` from another template | + +If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://:9000?streamid=#!::r=,m=publish…` (the host comes from `serverUrl`). + +Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites the roverd + video-publisher binaries and drops the latest systemd units so the only configuration you ever touch manually is `/etc/roverd.yaml`. `roverd` rewrites `/var/lib/roverd/video.env` on startup, so no other files need editing. +## Manual installation + +1. Copy the binary and config: + ```bash + sudo useradd -r -s /usr/sbin/nologin roverd || true + sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd + sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml + ``` + Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://:9000?streamid=#!::r=,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`. + +2. Install the systemd unit: + ```bash + sudo install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service + sudo systemctl daemon-reload + sudo systemctl enable --now roverd.service + ``` + +`roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod. +The video publisher needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds the `roverd` service account to the `video` group; if you created the user manually, make sure it belongs to `video`. +**BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if you’re not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore. +If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl ` (the installer wires `video-publisher.service` to run as `roverd`, so no sudo tweaks are required unless you rename it). + +## Video publisher details + +The `video-publisher.service` unit runs `/usr/local/bin/video-publisher`, piping `rpicam-vid`/`libcamera-vid` straight into the stock FFmpeg package: + +```bash +rpicam-vid (or libcamera-vid) --inline --timeout 0 --width=WIDTH --height=HEIGHT \ + --framerate=FPS --bitrate=BITRATE --codec h264 --profile baseline --output - \ + | ffmpeg -hide_banner -loglevel warning -fflags nobuffer \ + -f h264 -i pipe:0 -c:v copy -an -flush_packets 1 -f mpegts $PUBLISH_URL +``` + +`/var/lib/roverd/video.env` carries all tunables (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites the file whenever you restart it or hit the “Restart Camera” button so the publisher always inherits the correct rover ID + bitrate knobs. Because we now use the distro FFmpeg build with SRT enabled, the installer is much faster—no custom compile steps. +Use `sudo systemctl status video-publisher` to watch logs; the unit auto-restarts whenever the connection drops or FFmpeg exits with an error. + +## Server + UI + +From the repo root: + +```bash +cd server +npm install +npm run start +``` + +This launches the HTTP server (serving the barebones UI) and the rover WebSocket endpoint at `ws://:8080/rover`. The UI expects `roverd` instances to send `hello` frames so it can populate the rover list. Use the mode buttons to emit Start/Safe/Full/Passive/Dock commands (they send the raw OI opcode bytes), tap the sensor toggle to request Group 100 streaming, and use WASD for drive testing; the UI emits Drive Direct commands ~8 times per second, so the rover sees them immediately. diff --git a/new_video_pipeline.md b/new_video_pipeline.md new file mode 100644 index 00000000..5a2e2be4 --- /dev/null +++ b/new_video_pipeline.md @@ -0,0 +1,36 @@ +# Video flow (SRT ingest, WHEP playback) + +1. **Raspberry Pi Zero 2 W** + - `pi/bin/video-publisher.sh` captures the CSI camera with `rpicam-vid`/`libcamera-vid` using the onboard H.264 encoder (`--inline --profile baseline --bitrate …`). + - The Annex-B stream is piped into the stock FFmpeg package and pushed to the control server over SRT as MPEG‑TS: `ffmpeg -f h264 -i - -c copy -f mpegts "srt://:9000?streamid=#!::r=rover-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316"`. + - Configuration lives in `/var/lib/roverd/video.env` (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites this file whenever the rover config changes, so onboarding a new rover is just flashing the SD card, setting its `name`, and plugging it into the trusted LAN—**no auth or per-rover server config is required on the Pi ⇄ server hop.** + +2. **mediaMTX on the server** + - Single wildcard path handles every rover: + ```yaml + paths: + "~^rover-(?P[a-z0-9_-]+)$": + source: publisher + sourceOnDemand: no + sourceProtocol: srt + readBufferCount: 512 + webrtcEnable: yes + webrtcMaxPlayoutDelay: 0 + alwaysRemuxWhep: no + ``` + - Pis publish to `srt://:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC. + - Only playback is gated: mediaMTX calls the Node server to validate JWTs on `/whep/rover-`, so “locking” a stream is as simple as refusing to mint viewer tokens for that rover. Ingest stays unauthenticated because it lives on a secure LAN. + +3. **Web clients** + - Tiny helper (React hook or vanilla class) that: + 1. Requests a viewer token for rover ``. + 2. Issues a `POST` to `//whep` with `Authorization: Basic base64(token:token)` (token issued by the server when the client calls `video:request`). + 3. Maintains auto-reconnect timers on ICE failure so dashboard widgets can come/go without reloading the page. + - Operator dashboard mounts one player tied to the assigned rover. The spectator view instantiates one player per tile, muting + pausing hidden elements to keep CPU usage sane even when every rover is shown simultaneously. + +# Deployment checklist + +- `pi/install_roverd.sh` installs the `video-publisher` helper, drops `/var/lib/roverd/video.env`, and pulls in `libcamera-apps` + `ffmpeg` from apt. No custom FFmpeg, no WHIP builds, no extra config—Pis become plug-and-play. +- `roverd` derives `media.publishUrl` automatically from `serverUrl`: `srt://:9000?streamid=#!::r=,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316`. The media supervisor rewrites `video.env` and manages `video-publisher.service` whenever you hit “Restart Camera” or change `/etc/roverd.yaml`. +- The server’s mediaMTX config switches to the wildcard block above, leaves SRT ingest open, and enforces JWTs only on WHEP viewers. Fan-out stays inside mediaMTX so every browser sees the same low-latency stream (≈250–350 ms glass-to-glass). +- Adding a rover = flash SD → set `/etc/roverd.yaml` (`name`, `serverUrl`, camera knobs if needed) → boot it. The server auto-discovers the new `rover-` stream with zero manual edits. diff --git a/outline.md b/outline.md index d0a9aef6..7efecf3b 100644 --- a/outline.md +++ b/outline.md @@ -1,40 +1,38 @@ -## esp32 firmware -- hooked up to the roomba's UART on pins 16 and 17 -- pin 5 is connected to the roomba's BRC pin - - pulse the BRC pin low for 1 second every minute to keep the roomba awake -- connect to wifi -- connect to the server -- get a full frame of sensor group 100 from the roomba every 500ms - - send it to the server over the sensor UDP stream -- listen to the server's control UDP stream (per roomba) and do the following accordingly: - - set wheel speeds - - seek dock - - enable OI - - safe mode - - full mode - - play song - - load song +## multi roomba rover -## esp32 -> server communication -- one UDP stream to the esp32 for controlling the roomba - - might look like this: - - left wheel speed - - right wheel speed - - OI mode - - seek dock? - - blasts out at a constant rate from the server for each roomba - - the esp32 will listen, and follow the latest command that it sees -- one UDP stream from the esp32 to the server for sending sensor data frames and other telemetry - - one full frame of sensor data per datagram - - send raw sensor data, the server will decode it - - add other telemetry from the esp32, like signal strength, etc. - - maybe use this stream as a sign that the esp32 is still running healthily? +a website where people can control multiple irobot create 2 robots in real time 100% responsively +with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera +a central nodejs control server will tell the raspberry pis what to do with the roomba + +## the pi side (roomba side) +- pi's onboard UART is hooked up to the roomba's serial port +- another GPIO pin connected to the roomba's BRC pin + - pull it low for one second every minute to keep the roomba awake +- streams the rpi camera over webRTC with mediamtx +- streams roomba sensor group 100 to the server + - sensor streaming is required and important, but is allowed to falter sometimes +- listens for roomba commands from the server + - commands NEED to happen +- roomba control program needs to be simple, lightweight, and 100% responsive + +## pi -> server communication +- stateless +- streaming based +- on connection, the pi will send the following info: + - rover's name + - motor enable / disable + - vacuum + - main brush + - side brush + - battery full number + - battery warning number + - battery urgent number ## nodejs server - KISS - decode the sensor data from each roomba - can support multiple roombas connected from the ground up -- keep it simple, worry about getting the esp32 firmware right. +- keep it simple, worry about getting the pi comms right. - but the server DOES have to exist for testing - IS the web server, hosts an entire static folder for the web UI @@ -57,11 +55,4 @@ - one entrypoint file in the web UI - everything modular - everything easy to read, understand, and work on -- comment where you think is best to describe whats going on - -## closing notes -- keep the user input path (web UI -> server -> roomba) as light and responsive as possible. responsiveness is key for this. -- responsiveness is the name of the game. The future of this program is teleoperation over the internet, with a camera on each roomba. keyboard inputs from the user must be near instant. -- on the esp32 firmware side of things, sensor data is second priority to having a responsive control system - - but sensor data DOES have to exist. -- the future of this project will involve assigning one roomba to a user, make the server able to do that from the ground up. \ No newline at end of file +- comment where you think is best to describe whats going on \ No newline at end of file diff --git a/pi/bin/video-publisher.sh b/pi/bin/video-publisher.sh new file mode 100644 index 00000000..83e4cb11 --- /dev/null +++ b/pi/bin/video-publisher.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Environment file ${ENV_FILE} missing; cannot publish" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +source "$ENV_FILE" + +: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}" + +VIDEO_WIDTH="${VIDEO_WIDTH:-1920}" +VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}" +VIDEO_FPS="${VIDEO_FPS:-30}" +VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}" + +if [[ -n "${LIBCAMERA_BIN:-}" ]]; then + LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN" +elif command -v rpicam-vid >/dev/null 2>&1; then + LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)" +elif command -v libcamera-vid >/dev/null 2>&1; then + LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)" +else + echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2 + exit 1 +fi + +if [[ -n "${FFMPEG_BIN:-}" ]]; then + FFMPEG_BIN_PATH="$FFMPEG_BIN" +elif command -v ffmpeg >/dev/null 2>&1; then + FFMPEG_BIN_PATH="$(command -v ffmpeg)" +else + echo "ffmpeg not found; install it via apt install ffmpeg." >&2 + exit 1 +fi + +run_pipeline() { + "${LIBCAMERA_BIN_PATH}" \ + --inline \ + --timeout 0 \ + --width "${VIDEO_WIDTH}" \ + --height "${VIDEO_HEIGHT}" \ + --framerate "${VIDEO_FPS}" \ + --bitrate "${VIDEO_BITRATE}" \ + --codec h264 \ + --profile baseline \ + --denoise cdn_off \ + --nopreview \ + --output - \ + | "${FFMPEG_BIN_PATH}" \ + -hide_banner \ + -loglevel warning \ + -fflags nobuffer \ + -use_wallclock_as_timestamps 1 \ + -f h264 \ + -i pipe:0 \ + -c:v copy \ + -an \ + -flush_packets 1 \ + -f mpegts \ + "${PUBLISH_URL}" +} + +while true; do + if run_pipeline; then + exit 0 + fi + echo "Video publisher exited, restarting in 2s..." >&2 + sleep 2 +done diff --git a/pi/install_roverd.sh b/pi/install_roverd.sh new file mode 100755 index 00000000..f380b9c8 --- /dev/null +++ b/pi/install_roverd.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# +# Installer for the roverd agent on Raspberry Pi + +set -euo pipefail + +BINARY_SRC="dist/roverd" +CONFIG_SRC="pi/roverd/roverd.sample.yaml" + +usage() { + cat <<'USAGE' +Usage: sudo ./pi/install_roverd.sh [options] + +Options: + -b, --binary Path to the roverd binary (default: dist/roverd) + -c, --config Source config to install if /etc/roverd.yaml is missing + (default: pi/roverd/roverd.sample.yaml) + -h, --help Show this help text + +The script must run from the repository root and as root (sudo). It will: + * create system users/groups if needed + * install /usr/local/bin/roverd and /etc/roverd.yaml + * install /usr/local/bin/video-publisher and its systemd unit + * enable roverd.service and video-publisher.service +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -b|--binary) + BINARY_SRC="${2:-}" + shift 2 + ;; + -c|--config) + CONFIG_SRC="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ "${EUID}" -ne 0 ]]; then + echo "Please run as root (sudo)" >&2 + exit 1 +fi + +if [[ ! -f "$BINARY_SRC" ]]; then + echo "Binary not found at $BINARY_SRC" >&2 + exit 1 +fi + +if [[ ! -f "$CONFIG_SRC" ]]; then + echo "Config source not found at $CONFIG_SRC" >&2 + exit 1 +fi + +ensure_user() { + local user="$1" + local groups="${2:-}" + if ! id -u "$user" >/dev/null 2>&1; then + if [[ -n "$groups" ]]; then + useradd -r -s /usr/sbin/nologin -G "$groups" "$user" + else + useradd -r -s /usr/sbin/nologin "$user" + fi + elif [[ -n "$groups" ]]; then + usermod -a -G "$groups" "$user" + fi +} + +log() { + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" +} + +if ! command -v rpicam-vid >/dev/null 2>&1 && ! command -v libcamera-vid >/dev/null 2>&1; then + log "WARNING: neither rpicam-vid nor libcamera-vid found in PATH; install libcamera-apps." +fi + +install_video_deps() { + if command -v ffmpeg >/dev/null 2>&1 && (command -v rpicam-vid >/dev/null 2>&1 || command -v libcamera-vid >/dev/null 2>&1); then + log "Video dependencies already installed; skipping apt install" + return + fi + log "Installing video dependencies (libcamera-apps, ffmpeg)..." + apt-get update + apt-get install -y --no-install-recommends libcamera-apps ffmpeg +} + +ensure_user roverd "dialout,gpio,video,render" +install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd +log "Installed roverd binary" + +CONFIG_DEST="/etc/roverd.yaml" +CONFIG_EXISTS=0 +if [[ -f "$CONFIG_DEST" ]]; then + CONFIG_EXISTS=1 + log "Existing $CONFIG_DEST found; leaving it in place" +else + install -D -o roverd -g roverd -m 0640 "$CONFIG_SRC" "$CONFIG_DEST" + log "Installed sample config to $CONFIG_DEST (edit before starting service)" +fi + +install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service +log "Installed roverd systemd unit" + +install_video_deps + +# Install video publisher assets +install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher +install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service +install -d -o roverd -g roverd /var/lib/roverd +if [[ ! -f /var/lib/roverd/video.env ]]; then + cat > /var/lib/roverd/video.env <<'ENV' +# Managed by roverd; placeholder values will be overwritten at runtime. +PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316 +VIDEO_WIDTH=1280 +VIDEO_HEIGHT=720 +VIDEO_FPS=30 +VIDEO_BITRATE=3000000 +ENV + chown roverd:roverd /var/lib/roverd/video.env + chmod 0640 /var/lib/roverd/video.env +fi + +systemctl daemon-reload +systemctl enable roverd.service +systemctl enable video-publisher.service +if [[ $CONFIG_EXISTS -eq 1 ]]; then + systemctl restart roverd.service + systemctl restart video-publisher.service + log "Restarted roverd + video publisher" +else + log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher" +fi + +log "Install complete" diff --git a/pi/roverd/Makefile b/pi/roverd/Makefile new file mode 100644 index 00000000..5aff6e57 --- /dev/null +++ b/pi/roverd/Makefile @@ -0,0 +1,18 @@ +BIN_DIR ?= ../../dist +GOOS ?= linux +GOARCH ?= arm +GOARM ?= 6 + +.PHONY: build pi-build dummy clean + +build: + go build -o $(BIN_DIR)/roverd ./cmd/roverd + +pi-build: + GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd + +dummy: + GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd + +clean: + rm -f $(BIN_DIR)/roverd diff --git a/pi/roverd/auto_charge.go b/pi/roverd/auto_charge.go new file mode 100644 index 00000000..8d043fa1 --- /dev/null +++ b/pi/roverd/auto_charge.go @@ -0,0 +1,109 @@ +package roverd + +import ( + "context" + "log" + "time" +) + +const ( + autoChargeTimeout = 10 * time.Second + autoChargeCooldown = 0 * time.Minute + sourceHomeBase = 1 << 1 +) + +type AutoChargeController struct { + adapter *SerialAdapter + events chan<- RoverEvent + logger *log.Logger + timerStart time.Time + cooldownUntil time.Time + lastState byte + lastSources byte +} + +func NewAutoChargeController(adapter *SerialAdapter, events chan<- RoverEvent, logger *log.Logger) *AutoChargeController { + return &AutoChargeController{ + adapter: adapter, + events: events, + logger: logger, + } +} + +func (a *AutoChargeController) Run(ctx context.Context, samples <-chan SensorSample) { + for { + select { + case <-ctx.Done(): + return + case sample := <-samples: + a.processSample(sample) + } + } +} + +func (a *AutoChargeController) processSample(sample SensorSample) { + now := time.Now() + docked := sample.ChargeSources&sourceHomeBase != 0 + charging := isCharging(sample.ChargingState) + + if !docked || charging { + if !a.timerStart.IsZero() { + a.emitEvent("autoCharge.timerCleared", map[string]any{ + "durationMs": time.Since(a.timerStart).Milliseconds(), + }) + } + a.timerStart = time.Time{} + a.lastState = sample.ChargingState + a.lastSources = sample.ChargeSources + return + } + + // docked but not charging + if a.cooldownUntil.After(now) { + return + } + + if a.timerStart.IsZero() { + a.timerStart = now + a.emitEvent("autoCharge.timerStarted", map[string]any{ + "chargingState": sample.ChargingState, + }) + return + } + + if now.Sub(a.timerStart) >= autoChargeTimeout { + if err := a.adapter.SeekDock(); err != nil { + a.emitEvent("autoCharge.seekDockError", map[string]any{"error": err.Error()}) + } else { + a.emitEvent("autoCharge.seekDockIssued", map[string]any{ + "waitingMs": autoChargeTimeout.Milliseconds(), + }) + } + a.timerStart = time.Time{} + a.cooldownUntil = now.Add(autoChargeCooldown) + } +} + +func isCharging(state byte) bool { + switch state { + case 1, 2, 3, 4: + return true + default: + return false + } +} + +func (a *AutoChargeController) emitEvent(event string, data map[string]any) { + if a.events == nil { + return + } + select { + case a.events <- RoverEvent{ + Type: "event", + Event: event, + Ts: time.Now().UnixMilli(), + Data: data, + }: + default: + } +} diff --git a/pi/roverd/brc.go b/pi/roverd/brc.go new file mode 100644 index 00000000..005ffdf2 --- /dev/null +++ b/pi/roverd/brc.go @@ -0,0 +1,73 @@ +//go:build !dummy + +package roverd + +import ( + "context" + "log" + "time" + + gpiocdev "github.com/warthog618/go-gpiocdev" +) + +type BRCPulser struct { + cfg BRCConfig + logger *log.Logger + line *gpiocdev.Line +} + +func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) { + chip := cfg.GPIOChip + if chip == "" { + chip = "gpiochip0" + } + + line, err := gpiocdev.RequestLine( + chip, + cfg.GPIOPin, + gpiocdev.AsOutput(1), + gpiocdev.WithConsumer("roverd-brc"), + ) + if err != nil { + return nil, err + } + + return &BRCPulser{cfg: cfg, logger: logger, line: line}, nil +} + +func (b *BRCPulser) Close() { + if b.line != nil { + _ = b.line.SetValue(1) + b.line.Close() + } +} + +func (b *BRCPulser) Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(b.cfg.PulseEvery.Duration) + defer ticker.Stop() + + for { + b.pulseOnce() + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() +} + +func (b *BRCPulser) pulseOnce() { + if b.line == nil { + return + } + if err := b.line.SetValue(0); err != nil { + b.logger.Printf("brc pulse low: %v", err) + return + } + time.Sleep(b.cfg.PulseWidth.Duration) + if err := b.line.SetValue(1); err != nil { + b.logger.Printf("brc pulse high: %v", err) + } +} diff --git a/pi/roverd/brc_dummy.go b/pi/roverd/brc_dummy.go new file mode 100644 index 00000000..40a2d736 --- /dev/null +++ b/pi/roverd/brc_dummy.go @@ -0,0 +1,19 @@ +//go:build dummy + +package roverd + +import ( + "context" + "log" +) + +type BRCPulser struct{} + +func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) { + logger.Printf("[dummy] BRC configured on pin %d", cfg.GPIOPin) + return &BRCPulser{}, nil +} + +func (b *BRCPulser) Close() {} + +func (b *BRCPulser) Start(ctx context.Context) {} diff --git a/pi/roverd/cmd/roverd/main.go b/pi/roverd/cmd/roverd/main.go new file mode 100644 index 00000000..db257c71 --- /dev/null +++ b/pi/roverd/cmd/roverd/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + "time" + + roverd "multiroombarover/pi/roverd" +) + +func main() { + var cfgPath string + flag.StringVar(&cfgPath, "config", "/etc/roverd.yaml", "path to roverd configuration file") + flag.Parse() + + cfg, err := roverd.LoadConfig(cfgPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil { + log.Fatalf("prepare media env: %v", err) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC) + + serialPort, err := roverd.OpenSerial(cfg.Serial) + if err != nil { + logger.Fatalf("open serial: %v", err) + } + defer serialPort.Close() + + var pulser *roverd.BRCPulser + if cfg.BRC.Enabled() { + pulser, err = roverd.NewBRCPulser(cfg.BRC, logger) + if err != nil { + logger.Fatalf("init BRC pulser: %v", err) + } + defer pulser.Close() + pulser.Start(ctx) + } + + sensorFrames := make(chan []byte, 8) + sensorSamples := make(chan roverd.SensorSample, 8) + eventStream := make(chan roverd.RoverEvent, 16) + + streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, sensorSamples, logger) + go streamer.Run(ctx) + + adapter := roverd.NewSerialAdapter(serialPort, logger) + + mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger) + if mediaSupervisor != nil { + mediaSupervisor.Start(ctx) + } + + autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger) + go autoCharge.Run(ctx, sensorSamples) + + client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, logger) + + retryDelay := time.Second + for ctx.Err() == nil { + if err := client.Run(ctx); err != nil { + logger.Printf("websocket loop ended: %v", err) + } + + select { + case <-ctx.Done(): + return + case <-time.After(retryDelay): + } + + if retryDelay < 30*time.Second { + retryDelay *= 2 + } + } +} diff --git a/pi/roverd/command.go b/pi/roverd/command.go new file mode 100644 index 00000000..2bb3a494 --- /dev/null +++ b/pi/roverd/command.go @@ -0,0 +1,51 @@ +package roverd + +type helloMessage struct { + Type string `json:"type"` + Name string `json:"name"` + Battery BatteryConfig `json:"battery"` + MaxWheelSpeed int `json:"maxWheelSpeed"` + Media MediaConfig `json:"media"` +} + +type sensorMessage struct { + Type string `json:"type"` + Timestamp int64 `json:"ts"` + Data string `json:"data"` +} + +type inboundMessage struct { + Type string `json:"type"` + ID string `json:"id"` + DriveDirect *driveDirectPayload `json:"driveDirect,omitempty"` + MotorPWM *motorPWMPayload `json:"motorPwm,omitempty"` + Raw string `json:"raw,omitempty"` + SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"` + Media *mediaCommand `json:"media,omitempty"` +} + +type driveDirectPayload struct { + Left int `json:"left"` + Right int `json:"right"` +} + +type motorPWMPayload struct { + Main int `json:"main"` + Side int `json:"side"` + Vacuum int `json:"vacuum"` +} + +type sensorStreamPayload struct { + Enable bool `json:"enable"` +} + +type mediaCommand struct { + Action string `json:"action"` +} + +type ackMessage struct { + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} diff --git a/pi/roverd/config.go b/pi/roverd/config.go new file mode 100644 index 00000000..e32dcfe8 --- /dev/null +++ b/pi/roverd/config.go @@ -0,0 +1,174 @@ +package roverd + +import ( + "errors" + "fmt" + "net/url" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +type SerialConfig struct { + Device string `yaml:"device"` + Baud int `yaml:"baud"` +} + +type Duration struct { + time.Duration +} + +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + var raw string + if err := value.Decode(&raw); err != nil { + return err + } + parsed, err := time.ParseDuration(raw) + if err != nil { + return err + } + d.Duration = parsed + return nil +} + +func (d Duration) MarshalYAML() (interface{}, error) { + return d.Duration.String(), nil +} + +type BRCConfig struct { + GPIOPin int `yaml:"gpioPin"` + GPIOChip string `yaml:"gpioChip"` + PulseEvery Duration `yaml:"pulseEvery"` + PulseWidth Duration `yaml:"pulseWidth"` +} + +func (b BRCConfig) Enabled() bool { + return b.GPIOPin >= 0 +} + +type BatteryConfig struct { + Full int `yaml:"full"` + Warn int `yaml:"warn"` + Urgent int `yaml:"urgent"` +} + +type MediaConfig struct { + PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` + PublishPort int `yaml:"publishPort" json:"-"` + Manage bool `yaml:"manage"` + Service string `yaml:"service"` + HealthURL string `yaml:"healthUrl"` + HealthInterval Duration `yaml:"healthInterval"` + VideoWidth int `yaml:"videoWidth" json:"-"` + VideoHeight int `yaml:"videoHeight" json:"-"` + VideoFPS int `yaml:"videoFps" json:"-"` + VideoBitrate int `yaml:"videoBitrate" json:"-"` +} + +type Config struct { + Name string `yaml:"name"` + ServerURL string `yaml:"serverUrl"` + Serial SerialConfig `yaml:"serial"` + BRC BRCConfig `yaml:"brc"` + Battery BatteryConfig `yaml:"battery"` + MaxWheelMMs int `yaml:"maxWheelSpeed"` + Media MediaConfig `yaml:"media"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + cfg := Config{ + MaxWheelMMs: 500, + BRC: BRCConfig{ + GPIOPin: -1, + GPIOChip: "gpiochip0", + PulseEvery: Duration{ + Duration: time.Minute, + }, + PulseWidth: Duration{ + Duration: time.Second, + }, + }, + Media: MediaConfig{ + PublishPort: 9000, + HealthInterval: Duration{Duration: 30 * time.Second}, + VideoWidth: 1280, + VideoHeight: 720, + VideoFPS: 30, + VideoBitrate: 3000000, + }, + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.Name == "" { + return nil, errors.New("missing name") + } + if cfg.ServerURL == "" { + return nil, errors.New("missing serverUrl") + } + if cfg.Serial.Device == "" || cfg.Serial.Baud == 0 { + return nil, errors.New("serial device/baud required") + } + if cfg.Battery.Full == 0 { + return nil, errors.New("battery thresholds required") + } + if cfg.MaxWheelMMs <= 0 || cfg.MaxWheelMMs > 500 { + return nil, fmt.Errorf("maxWheelSpeed must be 1-500, got %d", cfg.MaxWheelMMs) + } + if cfg.BRC.GPIOChip == "" { + cfg.BRC.GPIOChip = "gpiochip0" + } + if cfg.Media.Manage && cfg.Media.Service == "" { + return nil, errors.New("media.manage requires media.service") + } + if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 { + cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second} + } + if cfg.Media.VideoWidth <= 0 { + cfg.Media.VideoWidth = 1280 + } + if cfg.Media.VideoHeight <= 0 { + cfg.Media.VideoHeight = 720 + } + if cfg.Media.VideoFPS <= 0 { + cfg.Media.VideoFPS = 30 + } + if cfg.Media.VideoBitrate <= 0 { + cfg.Media.VideoBitrate = 3000000 + } + if cfg.Media.PublishPort <= 0 { + cfg.Media.PublishPort = 9000 + } + if cfg.Media.PublishURL == "" { + derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort) + if err != nil { + return nil, fmt.Errorf("derive publishUrl: %w", err) + } + cfg.Media.PublishURL = derived + } + return &cfg, nil +} + +func derivePublishURL(serverURL, roverName string, port int) (string, error) { + if roverName == "" { + return "", errors.New("missing rover name for publishUrl") + } + parsed, err := url.Parse(serverURL) + if err != nil { + return "", err + } + host := parsed.Hostname() + if host == "" { + return "", errors.New("serverUrl missing host") + } + if port <= 0 { + port = 9000 + } + streamName := url.PathEscape(roverName) + return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316", host, port, streamName), nil +} diff --git a/pi/roverd/events.go b/pi/roverd/events.go new file mode 100644 index 00000000..598db260 --- /dev/null +++ b/pi/roverd/events.go @@ -0,0 +1,8 @@ +package roverd + +type RoverEvent struct { + Type string `json:"type"` + Event string `json:"event"` + Ts int64 `json:"ts"` + Data map[string]any `json:"data,omitempty"` +} diff --git a/pi/roverd/go.mod b/pi/roverd/go.mod new file mode 100644 index 00000000..a69b6c2a --- /dev/null +++ b/pi/roverd/go.mod @@ -0,0 +1,12 @@ +module multiroombarover/pi/roverd + +go 1.25.4 + +require ( + github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 + github.com/warthog618/go-gpiocdev v0.9.1 + gopkg.in/yaml.v3 v3.0.1 + nhooyr.io/websocket v1.8.17 +) + +require golang.org/x/sys v0.38.0 // indirect diff --git a/pi/roverd/go.sum b/pi/roverd/go.sum new file mode 100644 index 00000000..819bfeeb --- /dev/null +++ b/pi/roverd/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI= +github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8= +github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4= +github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/pi/roverd/media_env.go b/pi/roverd/media_env.go new file mode 100644 index 00000000..3a69fca4 --- /dev/null +++ b/pi/roverd/media_env.go @@ -0,0 +1,32 @@ +package roverd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" +) + +const publisherEnvPath = "/var/lib/roverd/video.env" + +func UpdatePublisherEnv(cfg MediaConfig) error { + if cfg.PublishURL == "" { + return fmt.Errorf("media publishUrl missing") + } + if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 { + return fmt.Errorf("invalid media dimensions/bitrate") + } + if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil { + return err + } + var buf bytes.Buffer + fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL) + fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth) + fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight) + fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS) + fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate) + if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil { + return err + } + return nil +} diff --git a/pi/roverd/media_supervisor.go b/pi/roverd/media_supervisor.go new file mode 100644 index 00000000..f4558ee4 --- /dev/null +++ b/pi/roverd/media_supervisor.go @@ -0,0 +1,140 @@ +package roverd + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "os/exec" + "time" +) + +type MediaSupervisor struct { + cfg MediaConfig + logger *log.Logger + client *http.Client + checkInterval time.Duration +} + +func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor { + if err := UpdatePublisherEnv(cfg); err != nil { + logger.Printf("media supervisor: update env failed: %v", err) + } + if !cfg.Manage || cfg.Service == "" { + return nil + } + interval := cfg.HealthInterval.Duration + if interval <= 0 { + interval = 30 * time.Second + } + var client *http.Client + if cfg.HealthURL != "" { + client = &http.Client{Timeout: 5 * time.Second} + } + return &MediaSupervisor{ + cfg: cfg, + logger: logger, + client: client, + checkInterval: interval, + } +} + +func (m *MediaSupervisor) Start(ctx context.Context) { + if m == nil { + return + } + if err := UpdatePublisherEnv(m.cfg); err != nil { + m.logger.Printf("media supervisor: update env failed: %v", err) + } + if m.cfg.HealthURL == "" || m.client == nil { + return + } + go func() { + ticker := time.NewTicker(m.checkInterval) + defer ticker.Stop() + + if err := m.checkAndRepair(); err != nil { + m.logger.Printf("media supervisor: %v", err) + } + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.checkAndRepair(); err != nil { + m.logger.Printf("media supervisor: %v", err) + } + } + } + }() +} + +func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error { + if m == nil { + return errors.New("media supervisor disabled") + } + if err := UpdatePublisherEnv(m.cfg); err != nil { + return err + } + switch action { + case "start", "stop", "restart", "reload", "status": + return m.runSystemctl(ctx, action) + default: + return fmt.Errorf("unknown media action: %s", action) + } +} + +func (m *MediaSupervisor) checkAndRepair() error { + ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second) + defer cancel() + + if m.checkHealth(ctx) { + return nil + } + m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service) + if err := m.runSystemctl(ctx, "restart"); err != nil { + return fmt.Errorf("restart mediamtx: %w", err) + } + return nil +} + +func (m *MediaSupervisor) checkHealth(ctx context.Context) bool { + if m.client == nil || m.cfg.HealthURL == "" { + return true + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil) + if err != nil { + m.logger.Printf("media supervisor: health request: %v", err) + return false + } + resp, err := m.client.Do(req) + if err != nil { + m.logger.Printf("media supervisor: health request failed: %v", err) + return false + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return true + } + m.logger.Printf("media supervisor: unexpected health status %d", resp.StatusCode) + return false +} + +func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error { + if m.cfg.Service == "" { + return errors.New("no media service configured") + } + runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output)) + } + return nil +} diff --git a/pi/roverd/roverd b/pi/roverd/roverd new file mode 100755 index 00000000..8fbabf72 Binary files /dev/null and b/pi/roverd/roverd differ diff --git a/pi/roverd/roverd.sample.yaml b/pi/roverd/roverd.sample.yaml new file mode 100644 index 00000000..d990146f --- /dev/null +++ b/pi/roverd/roverd.sample.yaml @@ -0,0 +1,27 @@ +# Sample configuration for roverd +name: roomba-alpha +serverUrl: ws://control-server.local:8080/rover +serial: + device: /dev/ttyAMA0 + baud: 115200 +brc: + gpioPin: 17 + gpioChip: gpiochip0 + pulseEvery: 1m + pulseWidth: 1s +battery: + full: 2068 + warn: 1700 + urgent: 1650 +maxWheelSpeed: 350 +media: + publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316 + publishPort: 9000 + videoWidth: 1280 + videoHeight: 720 + videoFps: 30 + videoBitrate: 3000000 + manage: true + service: video-publisher.service + healthUrl: "" + healthInterval: 30s diff --git a/pi/roverd/roverd.yaml b/pi/roverd/roverd.yaml new file mode 100644 index 00000000..ed1deddd --- /dev/null +++ b/pi/roverd/roverd.yaml @@ -0,0 +1,21 @@ +# Sample configuration for roverd +name: roomba-alpha +serverUrl: ws://control-server.local:8080/rover +serial: + device: /dev/ttyAMA0 + baud: 115200 +brc: + gpioPin: 17 + gpioChip: gpiochip0 + pulseEvery: 1m + pulseWidth: 1s +battery: + full: 2068 + warn: 1700 + urgent: 1650 +maxWheelSpeed: 350 +media: + manage: false + service: mediamtx.service + healthUrl: http://127.0.0.1:9997/v3/paths/list + healthInterval: 30s diff --git a/pi/roverd/sensor_defs.go b/pi/roverd/sensor_defs.go new file mode 100644 index 00000000..857f196d --- /dev/null +++ b/pi/roverd/sensor_defs.go @@ -0,0 +1,23 @@ +package roverd + +var ( + defaultStreamPackets = []byte{100, 21, 34} + packetSizes = map[byte]int{ + 100: 80, + 21: 1, + 34: 1, + } + expectedPayloadLength = func() int { + sum := 0 + for _, id := range defaultStreamPackets { + sum += 1 + packetSizes[id] + } + return sum + }() +) + +type SensorSample struct { + Timestamp int64 + ChargingState byte + ChargeSources byte +} diff --git a/pi/roverd/sensor_stream.go b/pi/roverd/sensor_stream.go new file mode 100644 index 00000000..1365d2fa --- /dev/null +++ b/pi/roverd/sensor_stream.go @@ -0,0 +1,128 @@ +//go:build !dummy + +package roverd + +import ( + "bufio" + "context" + "encoding/hex" + "io" + "log" + "time" +) + +const ( + sensorHeader = 19 + sensorReadTimeout = 150 * time.Millisecond +) + +type SensorStreamer struct { + r io.Reader + rawOut chan<- []byte + parsed chan<- SensorSample + logger *log.Logger +} + +func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer { + return &SensorStreamer{r: r, rawOut: rawOut, parsed: parsed, logger: logger} +} + +func (s *SensorStreamer) Run(ctx context.Context) { + reader := bufio.NewReader(s.r) + for { + select { + case <-ctx.Done(): + return + default: + } + + header, err := reader.ReadByte() + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + if header != sensorHeader { + continue + } + nBytes, err := reader.ReadByte() + if err != nil { + continue + } + frame := make([]byte, int(nBytes)+3) + frame[0] = sensorHeader + frame[1] = nBytes + if _, err := io.ReadFull(reader, frame[2:]); err != nil { + continue + } + + if !validateChecksum(frame) { + s.logger.Printf("sensor checksum failed: %s", hex.EncodeToString(frame)) + continue + } + + select { + case s.rawOut <- frame: + default: + } + + if s.parsed != nil { + if sample, ok := decodeSensorSample(frame); ok { + select { + case s.parsed <- sample: + default: + } + } + } + } +} + +func validateChecksum(buf []byte) bool { + var sum int + for _, b := range buf { + sum += int(b) + } + return byte(sum&0xFF) == 0 +} + +func decodeSensorSample(frame []byte) (SensorSample, bool) { + if len(frame) < 3 { + return SensorSample{}, false + } + nBytes := int(frame[1]) + if nBytes+3 != len(frame) { + return SensorSample{}, false + } + payload := frame[2 : 2+nBytes] + if len(payload) != expectedPayloadLength { + return SensorSample{}, false + } + + idx := 0 + var sample SensorSample + var seen byte + for idx < len(payload) { + id := payload[idx] + idx++ + size, ok := packetSizes[id] + if !ok { + return SensorSample{}, false + } + if idx+size > len(payload) { + return SensorSample{}, false + } + segment := payload[idx : idx+size] + switch id { + case 21: + sample.ChargingState = segment[0] + seen |= 1 + case 34: + sample.ChargeSources = segment[0] + seen |= 2 + } + idx += size + } + sample.Timestamp = time.Now().UnixMilli() + return sample, seen&3 == 3 +} diff --git a/pi/roverd/sensor_stream_dummy.go b/pi/roverd/sensor_stream_dummy.go new file mode 100644 index 00000000..ed6b6cfd --- /dev/null +++ b/pi/roverd/sensor_stream_dummy.go @@ -0,0 +1,73 @@ +//go:build dummy + +package roverd + +import ( + "context" + "log" + "math/rand" + "time" +) + +const sensorHeader = 19 + +type SensorStreamer struct { + rawOut chan<- []byte + parsed chan<- SensorSample + logger *log.Logger +} + +func NewSensorStreamer(_ interface{}, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer { + return &SensorStreamer{rawOut: rawOut, parsed: parsed, logger: logger} +} + +func (s *SensorStreamer) Run(ctx context.Context) { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + frame := buildDummyFrame() + select { + case s.rawOut <- frame: + default: + } + sample := SensorSample{ + Timestamp: time.Now().UnixMilli(), + ChargingState: 3, // trickle charging + ChargeSources: 0b10, // home base present + } + select { + case s.parsed <- sample: + default: + } + } + } +} + +func buildDummyFrame() []byte { + payload := make([]byte, 0, expectedPayloadLength) + payload = append(payload, 100) + group := make([]byte, packetSizes[100]) + group[0] = byte(rand.Intn(16)) // bumps + payload = append(payload, group...) + payload = append(payload, 21, 3) + payload = append(payload, 34, 0b10) + + buf := make([]byte, 0, len(payload)+3) + buf = append(buf, sensorHeader, byte(len(payload))) + buf = append(buf, payload...) + checksum := calcChecksum(buf) + buf = append(buf, checksum) + return buf +} + +func calcChecksum(buf []byte) byte { + sum := 0 + for _, b := range buf { + sum += int(b) + } + return byte((-sum) & 0xFF) +} diff --git a/pi/roverd/serial.go b/pi/roverd/serial.go new file mode 100644 index 00000000..83893d87 --- /dev/null +++ b/pi/roverd/serial.go @@ -0,0 +1,97 @@ +//go:build !dummy + +package roverd + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "log" + "sync" + + "github.com/tarm/serial" +) + +type SerialAdapter struct { + port io.ReadWriteCloser + encoder *base64.Encoding + mu sync.Mutex + log *log.Logger +} + +func OpenSerial(cfg SerialConfig) (*serial.Port, error) { + return serial.OpenPort(&serial.Config{ + Name: cfg.Device, + Baud: cfg.Baud, + ReadTimeout: sensorReadTimeout, + }) +} + +func NewSerialAdapter(port io.ReadWriteCloser, logger *log.Logger) *SerialAdapter { + return &SerialAdapter{ + port: port, + encoder: base64.StdEncoding, + log: logger, + } +} + +func (s *SerialAdapter) write(buf []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + n, err := s.port.Write(buf) + if err != nil { + return err + } + if n != len(buf) { + return fmt.Errorf("short write %d/%d", n, len(buf)) + } + return nil +} + +func (s *SerialAdapter) DriveDirect(left, right int) error { + payload := []byte{ + 145, + byte((right >> 8) & 0xFF), + byte(right & 0xFF), + byte((left >> 8) & 0xFF), + byte(left & 0xFF), + } + return s.write(payload) +} + +func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error { + payload := []byte{ + 144, + byte(main & 0xFF), + byte(side & 0xFF), + byte(vacuum & 0xFF), + } + return s.write(payload) +} + +func (s *SerialAdapter) StartSensorStream(packets []byte) error { + if len(packets) == 0 { + return errors.New("sensor stream requires packets") + } + payload := []byte{148, byte(len(packets))} + payload = append(payload, packets...) + return s.write(payload) +} + +func (s *SerialAdapter) PauseSensorStream(pause bool) error { + state := byte(1) + if pause { + state = 0 + } + return s.write([]byte{150, state}) +} + +func (s *SerialAdapter) SendRaw(raw []byte) error { + return s.write(raw) +} + +func (s *SerialAdapter) SeekDock() error { + return s.write([]byte{143}) +} diff --git a/pi/roverd/serial_dummy.go b/pi/roverd/serial_dummy.go new file mode 100644 index 00000000..e359790b --- /dev/null +++ b/pi/roverd/serial_dummy.go @@ -0,0 +1,58 @@ +//go:build dummy + +package roverd + +import ( + "errors" + "io" + "log" +) + +type dummyPort struct{} + +func (dummyPort) Read(p []byte) (int, error) { return 0, io.EOF } +func (dummyPort) Write(p []byte) (int, error) { return len(p), nil } +func (dummyPort) Close() error { return nil } + +func OpenSerial(cfg SerialConfig) (io.ReadWriteCloser, error) { + return dummyPort{}, nil +} + +type SerialAdapter struct { + log *log.Logger +} + +func NewSerialAdapter(_ io.ReadWriteCloser, logger *log.Logger) *SerialAdapter { + return &SerialAdapter{log: logger} +} + +func (s *SerialAdapter) DriveDirect(left, right int) error { + s.log.Printf("[dummy] drive L=%d R=%d", left, right) + return nil +} + +func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error { + s.log.Printf("[dummy] motor main=%d side=%d vacuum=%d", main, side, vacuum) + return nil +} + +func (s *SerialAdapter) StartSensorStream(packets []byte) error { + if len(packets) == 0 { + return errors.New("sensor stream requires packets") + } + return nil +} + +func (s *SerialAdapter) PauseSensorStream(pause bool) error { + return nil +} + +func (s *SerialAdapter) SendRaw(raw []byte) error { + s.log.Printf("[dummy] raw %v", raw) + return nil +} + +func (s *SerialAdapter) SeekDock() error { + s.log.Printf("[dummy] seek dock") + return nil +} diff --git a/pi/roverd/wsclient.go b/pi/roverd/wsclient.go new file mode 100644 index 00000000..8c4849be --- /dev/null +++ b/pi/roverd/wsclient.go @@ -0,0 +1,220 @@ +package roverd + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "time" + + "nhooyr.io/websocket" +) + +type WSClient struct { + cfg *Config + adapter *SerialAdapter + sensorFrames <-chan []byte + events <-chan RoverEvent + media *MediaSupervisor + log *log.Logger +} + +func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, logger *log.Logger) *WSClient { + return &WSClient{ + cfg: cfg, + adapter: adapter, + sensorFrames: frames, + events: events, + media: media, + log: logger, + } +} + +func (c *WSClient) Run(ctx context.Context) error { + conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil) + if err != nil { + return err + } + defer conn.Close(websocket.StatusInternalError, "closed") + + if err := c.sendHello(ctx, conn); err != nil { + return err + } + if err := c.ensureSensorStream(); err != nil { + c.log.Printf("sensor stream init failed: %v", err) + } + + errCh := make(chan error, 1) + go func() { + errCh <- c.readLoop(ctx, conn) + }() + go c.forwardSensors(ctx, conn) + go c.forwardEvents(ctx, conn) + + select { + case <-ctx.Done(): + conn.Close(websocket.StatusNormalClosure, "context done") + return ctx.Err() + case err := <-errCh: + return err + } +} + +func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error { + msg := helloMessage{ + Type: "hello", + Name: c.cfg.Name, + Battery: c.cfg.Battery, + MaxWheelSpeed: c.cfg.MaxWheelMMs, + Media: c.cfg.Media, + } + return writeJSON(ctx, conn, msg) +} + +func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error { + for { + _, data, err := conn.Read(ctx) + if err != nil { + return err + } + var msg inboundMessage + if err := json.Unmarshal(data, &msg); err != nil { + c.log.Printf("invalid command: %v", err) + continue + } + if msg.ID == "" { + continue + } + status := "ok" + cmdErr := c.dispatch(ctx, &msg) + if cmdErr != nil { + status = "error" + } + ack := ackMessage{ + Type: "ack", + ID: msg.ID, + Status: status, + } + if cmdErr != nil { + ack.Error = cmdErr.Error() + } + if err := writeJSON(ctx, conn, ack); err != nil { + return err + } + } +} + +func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error { + switch { + case msg.DriveDirect != nil: + left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs) + right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs) + return c.adapter.DriveDirect(left, right) + case msg.MotorPWM != nil: + main := clamp(msg.MotorPWM.Main, -127, 127) + side := clamp(msg.MotorPWM.Side, -127, 127) + vac := clamp(msg.MotorPWM.Vacuum, 0, 127) + return c.adapter.MotorPWM(main, side, vac) + case msg.SensorStream != nil: + if msg.SensorStream.Enable { + if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { + return err + } + return c.adapter.PauseSensorStream(false) + } + return c.adapter.PauseSensorStream(true) + case msg.Raw != "" && len(msg.Raw) > 0: + buf, err := base64.StdEncoding.DecodeString(msg.Raw) + if err != nil { + return fmt.Errorf("raw decode: %w", err) + } + if err := c.adapter.SendRaw(buf); err != nil { + return err + } + if len(buf) > 0 && isModeOpcode(buf[0]) { + return c.ensureSensorStream() + } + return nil + case msg.Media != nil: + if c.media == nil { + return fmt.Errorf("media supervisor disabled") + } + return c.media.HandleAction(ctx, msg.Media.Action) + default: + return fmt.Errorf("unsupported command type: %s", msg.Type) + } +} + +func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) { + for { + select { + case <-ctx.Done(): + return + case frame := <-c.sensorFrames: + msg := sensorMessage{ + Type: "sensor", + Timestamp: time.Now().UnixMilli(), + Data: base64.StdEncoding.EncodeToString(frame), + } + if err := writeJSON(ctx, conn, msg); err != nil { + c.log.Printf("sensor send failed: %v", err) + return + } + } + } +} + +func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) { + if c.events == nil { + return + } + for { + select { + case <-ctx.Done(): + return + case evt := <-c.events: + if evt.Type == "" { + evt.Type = "event" + } + if err := writeJSON(ctx, conn, evt); err != nil { + c.log.Printf("event send failed: %v", err) + return + } + } + } +} + +func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + return conn.Write(ctx, websocket.MessageText, data) +} + +func clamp(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func (c *WSClient) ensureSensorStream() error { + if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil { + return err + } + return c.adapter.PauseSensorStream(false) +} + +func isModeOpcode(op byte) bool { + switch op { + case 128, 131, 132: + return true + default: + return false + } +} diff --git a/pi/systemd/roverd.service b/pi/systemd/roverd.service new file mode 100644 index 00000000..33bdd301 --- /dev/null +++ b/pi/systemd/roverd.service @@ -0,0 +1,16 @@ +[Unit] +Description=Multi-Roomba rover control agent +After=network-online.target mediamtx.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml +Restart=on-failure +RestartSec=5 +User=roverd +Group=roverd +AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO + +[Install] +WantedBy=multi-user.target diff --git a/pi/systemd/video-publisher.service b/pi/systemd/video-publisher.service new file mode 100644 index 00000000..761f8bd5 --- /dev/null +++ b/pi/systemd/video-publisher.service @@ -0,0 +1,17 @@ +[Unit] +Description=Rover Video Publisher (libcamera -> SRT) +After=network-online.target roverd.service +Wants=network-online.target + +[Service] +Type=simple +User=roverd +Group=roverd +WorkingDirectory=/var/lib/roverd +EnvironmentFile=/var/lib/roverd/video.env +ExecStart=/usr/local/bin/video-publisher +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/pi_camera_servo_outline.md b/pi_camera_servo_outline.md new file mode 100644 index 00000000..66632b32 --- /dev/null +++ b/pi_camera_servo_outline.md @@ -0,0 +1,13 @@ +# general idea +- servo connected to pin 19 on the pi + - it will allow the camera to look up and down +- use go-rpio + - it has a hardware PWM implementation +- mechanical design will have limits + - set the limits in roverd config +- control the servo from the web browser + - expose both a slider and buttons for "nudging" it up and down + +## verification of functionality before full implementation +- make sure that the go program can properly use the GPIO correctly before full implementation +- modify the roverd installer to add the needed stuff to enable PWM access \ No newline at end of file diff --git a/platformio.ini b/platformio.ini deleted file mode 100644 index 6576c2c2..00000000 --- a/platformio.ini +++ /dev/null @@ -1,4 +0,0 @@ -[env:esp32s3] -platform = espressif32 -board = esp32-s3-devkitc-1 -monitor_speed = 115200 \ No newline at end of file diff --git a/problems.md b/problems.md new file mode 100644 index 00000000..2474124c --- /dev/null +++ b/problems.md @@ -0,0 +1,17 @@ +## video stream +- video stream needs to always autoplay, even if it cant unmute itself + - currently, it wont play at all unless it can unmute +- video stream currently unloads and reloads based on the tab being active + - just remove this feature. It needs to stay running all the time. + +## server sensor decoding +- OI mode is not decoded properly + - says "safe" when in passive, and "unknown" when in full. + +## UI and session info +- UI needs a display for the turns mode + +## Mobile UI +- need to make it good +- joystick needs to be floating +- aux motor buttons need redone \ No newline at end of file diff --git a/server/config.example.yaml b/server/config.example.yaml new file mode 100644 index 00000000..06d2ffe5 --- /dev/null +++ b/server/config.example.yaml @@ -0,0 +1,13 @@ +admins: + - username: admin + password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass + discord_id: "1234567890" + lockdown: false + - username: lockdown + password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass + discord_id: "0987654321" + lockdown: true +media: + # Base address for mediaMTX (scheme + host + optional port). The UI will always request + # http:////whep + whepBaseUrl: "http://192.168.0.86:8889" diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..2f7bce17 --- /dev/null +++ b/server/index.js @@ -0,0 +1,22 @@ +require('./src/globals/logger'); +require('./src/globals/config'); +require('./src/globals/http'); +require('./src/globals/io'); +require('./src/globals/ws'); + +require('./src/helpers/sensorDecoder'); + +require('./src/services/alertService'); +require('./src/services/authService'); +require('./src/services/modeManager'); +require('./src/services/lockdownGuard'); +require('./src/services/roverManager'); +require('./src/services/commandService'); +require('./src/services/roverConnectionService'); +require('./src/services/assignmentService'); +require('./src/services/videoSessions'); +require('./src/services/videoAuthService'); +require('./src/services/videoSocketService'); +require('./src/services/logStreamService'); +require('./src/services/sessionService'); +require('./src/services/httpServer'); diff --git a/server/install_server.sh b/server/install_server.sh new file mode 100755 index 00000000..8071581d --- /dev/null +++ b/server/install_server.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +MEDIAMTX_VERSION="1.15.3" +MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}" +MEDIAMTX_BIN="/usr/local/bin/mediamtx" +MEDIAMTX_CONF_DIR="/etc/mediamtx" +MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml" +MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service" +MULTIROVER_SERVICE="/etc/systemd/system/multirover.service" + +if [[ $EUID -ne 0 ]]; then + echo "This installer must be run with sudo/root." >&2 + exit 1 +fi + +if [[ -z "${SUDO_USER:-}" || "${SUDO_USER}" == "root" ]]; then + echo "Run this script via 'sudo' from the normal user that owns the repo." >&2 + exit 1 +fi + +TARGET_USER="$SUDO_USER" +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SERVER_DIR="$SCRIPT_DIR" +CONFIG_PATH="$SERVER_DIR/config.yaml" +MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml" + +echo "[1/6] Installing dependencies..." +dnf install -y nodejs npm curl tar >/dev/null +NODE_BIN="$(command -v node)" + +echo "[2/6] Installing Node production deps..." +runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production" + +if [[ ! -f "$CONFIG_PATH" ]]; then + cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH" + chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH" + echo "Copied config.example.yaml to config.yaml; edit it before exposing the service." +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +arch=$(uname -m) +case "$arch" in + x86_64|amd64) + mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" + ;; + aarch64) + mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz" + ;; + armv7l) + mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz" + ;; + *) + echo "Unsupported architecture: $arch" >&2 + exit 1 + ;; +esac + +echo "[3/6] Installing mediaMTX ${MEDIAMTX_VERSION}..." +curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz" +tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx +install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN" + +mkdir -p "$MEDIAMTX_CONF_DIR" +if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then + echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2 + exit 1 +fi +echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG" +rm -f "$MEDIAMTX_CONFIG" +install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG" +chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR" + +echo "[4/6] Writing systemd units..." +cat > "$MEDIAMTX_SERVICE" < "$MULTIROVER_SERVICE" <= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/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/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "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.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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/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/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/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/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/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/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/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "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.13.0", + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "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.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/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/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/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", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/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/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "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.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "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/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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", + "engines": { + "node": ">= 0.8" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "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.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/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/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/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/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/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/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "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/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "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/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/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..697310b1 --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "multiroombarover-server", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + "check:media": "node scripts/checkMedia.js" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "express": "^4.19.2", + "js-yaml": "^4.1.1", + "morgan": "^1.10.0", + "socket.io": "^4.7.5", + "uuid": "^9.0.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/server/public/assets/index-BvgUoEqO.js b/server/public/assets/index-BvgUoEqO.js new file mode 100644 index 00000000..5fef3562 --- /dev/null +++ b/server/public/assets/index-BvgUoEqO.js @@ -0,0 +1,11 @@ +(function(){const i=document.createElement("link").relList;if(i&&i.supports&&i.supports("modulepreload"))return;for(const f of document.querySelectorAll('link[rel="modulepreload"]'))r(f);new MutationObserver(f=>{for(const d of f)if(d.type==="childList")for(const m of d.addedNodes)m.tagName==="LINK"&&m.rel==="modulepreload"&&r(m)}).observe(document,{childList:!0,subtree:!0});function c(f){const d={};return f.integrity&&(d.integrity=f.integrity),f.referrerPolicy&&(d.referrerPolicy=f.referrerPolicy),f.crossOrigin==="use-credentials"?d.credentials="include":f.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function r(f){if(f.ep)return;f.ep=!0;const d=c(f);fetch(f.href,d)}})();var lr={exports:{}},Ga={};var Zh;function M0(){if(Zh)return Ga;Zh=1;var u=Symbol.for("react.transitional.element"),i=Symbol.for("react.fragment");function c(r,f,d){var m=null;if(d!==void 0&&(m=""+d),f.key!==void 0&&(m=""+f.key),"key"in f){d={};for(var b in f)b!=="key"&&(d[b]=f[b])}else d=f;return f=d.ref,{$$typeof:u,type:r,key:m,ref:f!==void 0?f:null,props:d}}return Ga.Fragment=i,Ga.jsx=c,Ga.jsxs=c,Ga}var Kh;function D0(){return Kh||(Kh=1,lr.exports=M0()),lr.exports}var p=D0(),ar={exports:{}},I={};var Jh;function j0(){if(Jh)return I;Jh=1;var u=Symbol.for("react.transitional.element"),i=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),f=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),m=Symbol.for("react.context"),b=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),v=Symbol.for("react.memo"),T=Symbol.for("react.lazy"),S=Symbol.for("react.activity"),C=Symbol.iterator;function z(_){return _===null||typeof _!="object"?null:(_=C&&_[C]||_["@@iterator"],typeof _=="function"?_:null)}var L={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},X=Object.assign,q={};function H(_,B,V){this.props=_,this.context=B,this.refs=q,this.updater=V||L}H.prototype.isReactComponent={},H.prototype.setState=function(_,B){if(typeof _!="object"&&typeof _!="function"&&_!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,_,B,"setState")},H.prototype.forceUpdate=function(_){this.updater.enqueueForceUpdate(this,_,"forceUpdate")};function Y(){}Y.prototype=H.prototype;function k(_,B,V){this.props=_,this.context=B,this.refs=q,this.updater=V||L}var F=k.prototype=new Y;F.constructor=k,X(F,H.prototype),F.isPureReactComponent=!0;var ye=Array.isArray;function Ne(){}var $={H:null,A:null,T:null,S:null},Te=Object.prototype.hasOwnProperty;function xe(_,B,V){var Z=V.ref;return{$$typeof:u,type:_,key:B,ref:Z!==void 0?Z:null,props:V}}function Ue(_,B){return xe(_.type,B,_.props)}function Xe(_){return typeof _=="object"&&_!==null&&_.$$typeof===u}function We(_){var B={"=":"=0",":":"=2"};return"$"+_.replace(/[=:]/g,function(V){return B[V]})}var Hn=/\/+/g;function Xt(_,B){return typeof _=="object"&&_!==null&&_.key!=null?We(""+_.key):B.toString(36)}function Rt(_){switch(_.status){case"fulfilled":return _.value;case"rejected":throw _.reason;default:switch(typeof _.status=="string"?_.then(Ne,Ne):(_.status="pending",_.then(function(B){_.status==="pending"&&(_.status="fulfilled",_.value=B)},function(B){_.status==="pending"&&(_.status="rejected",_.reason=B)})),_.status){case"fulfilled":return _.value;case"rejected":throw _.reason}}throw _}function j(_,B,V,Z,ee){var le=typeof _;(le==="undefined"||le==="boolean")&&(_=null);var he=!1;if(_===null)he=!0;else switch(le){case"bigint":case"string":case"number":he=!0;break;case"object":switch(_.$$typeof){case u:case i:he=!0;break;case T:return he=_._init,j(he(_._payload),B,V,Z,ee)}}if(he)return ee=ee(_),he=Z===""?"."+Xt(_,0):Z,ye(ee)?(V="",he!=null&&(V=he.replace(Hn,"$&/")+"/"),j(ee,B,V,"",function(Wl){return Wl})):ee!=null&&(Xe(ee)&&(ee=Ue(ee,V+(ee.key==null||_&&_.key===ee.key?"":(""+ee.key).replace(Hn,"$&/")+"/")+he)),B.push(ee)),1;he=0;var Pe=Z===""?".":Z+":";if(ye(_))for(var De=0;De<_.length;De++)Z=_[De],le=Pe+Xt(Z,De),he+=j(Z,B,V,le,ee);else if(De=z(_),typeof De=="function")for(_=De.call(_),De=0;!(Z=_.next()).done;)Z=Z.value,le=Pe+Xt(Z,De++),he+=j(Z,B,V,le,ee);else if(le==="object"){if(typeof _.then=="function")return j(Rt(_),B,V,Z,ee);throw B=String(_),Error("Objects are not valid as a React child (found: "+(B==="[object Object]"?"object with keys {"+Object.keys(_).join(", ")+"}":B)+"). If you meant to render a collection of children, use an array instead.")}return he}function G(_,B,V){if(_==null)return _;var Z=[],ee=0;return j(_,Z,"","",function(le){return B.call(V,le,ee++)}),Z}function P(_){if(_._status===-1){var B=_._result;B=B(),B.then(function(V){(_._status===0||_._status===-1)&&(_._status=1,_._result=V)},function(V){(_._status===0||_._status===-1)&&(_._status=2,_._result=V)}),_._status===-1&&(_._status=0,_._result=B)}if(_._status===1)return _._result.default;throw _._result}var ve=typeof reportError=="function"?reportError:function(_){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var B=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof _=="object"&&_!==null&&typeof _.message=="string"?String(_.message):String(_),error:_});if(!window.dispatchEvent(B))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",_);return}console.error(_)},_e={map:G,forEach:function(_,B,V){G(_,function(){B.apply(this,arguments)},V)},count:function(_){var B=0;return G(_,function(){B++}),B},toArray:function(_){return G(_,function(B){return B})||[]},only:function(_){if(!Xe(_))throw Error("React.Children.only expected to receive a single React element child.");return _}};return I.Activity=S,I.Children=_e,I.Component=H,I.Fragment=c,I.Profiler=f,I.PureComponent=k,I.StrictMode=r,I.Suspense=y,I.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=$,I.__COMPILER_RUNTIME={__proto__:null,c:function(_){return $.H.useMemoCache(_)}},I.cache=function(_){return function(){return _.apply(null,arguments)}},I.cacheSignal=function(){return null},I.cloneElement=function(_,B,V){if(_==null)throw Error("The argument must be a React element, but you passed "+_+".");var Z=X({},_.props),ee=_.key;if(B!=null)for(le in B.key!==void 0&&(ee=""+B.key),B)!Te.call(B,le)||le==="key"||le==="__self"||le==="__source"||le==="ref"&&B.ref===void 0||(Z[le]=B[le]);var le=arguments.length-2;if(le===1)Z.children=V;else if(1>>1,_e=j[ve];if(0>>1;ve<_;){var B=2*(ve+1)-1,V=j[B],Z=B+1,ee=j[Z];if(0>f(V,P))Z<_e&&0>f(ee,V)?(j[ve]=ee,j[Z]=P,ve=Z):(j[ve]=V,j[B]=P,ve=B);else if(Z<_e&&0>f(ee,P))j[ve]=ee,j[Z]=P,ve=Z;else break e}}return G}function f(j,G){var P=j.sortIndex-G.sortIndex;return P!==0?P:j.id-G.id}if(u.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;u.unstable_now=function(){return d.now()}}else{var m=Date,b=m.now();u.unstable_now=function(){return m.now()-b}}var y=[],v=[],T=1,S=null,C=3,z=!1,L=!1,X=!1,q=!1,H=typeof setTimeout=="function"?setTimeout:null,Y=typeof clearTimeout=="function"?clearTimeout:null,k=typeof setImmediate<"u"?setImmediate:null;function F(j){for(var G=c(v);G!==null;){if(G.callback===null)r(v);else if(G.startTime<=j)r(v),G.sortIndex=G.expirationTime,i(y,G);else break;G=c(v)}}function ye(j){if(X=!1,F(j),!L)if(c(y)!==null)L=!0,Ne||(Ne=!0,We());else{var G=c(v);G!==null&&Rt(ye,G.startTime-j)}}var Ne=!1,$=-1,Te=5,xe=-1;function Ue(){return q?!0:!(u.unstable_now()-xej&&Ue());){var ve=S.callback;if(typeof ve=="function"){S.callback=null,C=S.priorityLevel;var _e=ve(S.expirationTime<=j);if(j=u.unstable_now(),typeof _e=="function"){S.callback=_e,F(j),G=!0;break t}S===c(y)&&r(y),F(j)}else r(y);S=c(y)}if(S!==null)G=!0;else{var _=c(v);_!==null&&Rt(ye,_.startTime-j),G=!1}}break e}finally{S=null,C=P,z=!1}G=void 0}}finally{G?We():Ne=!1}}}var We;if(typeof k=="function")We=function(){k(Xe)};else if(typeof MessageChannel<"u"){var Hn=new MessageChannel,Xt=Hn.port2;Hn.port1.onmessage=Xe,We=function(){Xt.postMessage(null)}}else We=function(){H(Xe,0)};function Rt(j,G){$=H(function(){j(u.unstable_now())},G)}u.unstable_IdlePriority=5,u.unstable_ImmediatePriority=1,u.unstable_LowPriority=4,u.unstable_NormalPriority=3,u.unstable_Profiling=null,u.unstable_UserBlockingPriority=2,u.unstable_cancelCallback=function(j){j.callback=null},u.unstable_forceFrameRate=function(j){0>j||125ve?(j.sortIndex=P,i(v,j),c(y)===null&&j===c(v)&&(X?(Y($),$=-1):X=!0,Rt(ye,P-ve))):(j.sortIndex=_e,i(y,j),L||z||(L=!0,Ne||(Ne=!0,We()))),j},u.unstable_shouldYield=Ue,u.unstable_wrapCallback=function(j){var G=C;return function(){var P=C;C=G;try{return j.apply(this,arguments)}finally{C=P}}}})(sr)),sr}var Fh;function U0(){return Fh||(Fh=1,ur.exports=w0()),ur.exports}var cr={exports:{}},Fe={};var Ph;function B0(){if(Ph)return Fe;Ph=1;var u=Mu();function i(y){var v="https://react.dev/errors/"+y;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(u)}catch(i){console.error(i)}}return u(),cr.exports=B0(),cr.exports}var em;function H0(){if(em)return Va;em=1;var u=U0(),i=Mu(),c=L0();function r(e){var t="https://react.dev/errors/"+e;if(1_e||(e.current=ve[_e],ve[_e]=null,_e--)}function V(e,t){_e++,ve[_e]=e.current,e.current=t}var Z=_(null),ee=_(null),le=_(null),he=_(null);function Pe(e,t){switch(V(le,t),V(ee,e),V(Z,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?ph(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=ph(t),e=yh(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}B(Z),V(Z,e)}function De(){B(Z),B(ee),B(le)}function Wl(e){e.memoizedState!==null&&V(he,e);var t=Z.current,n=yh(t,e.type);t!==n&&(V(ee,e),V(Z,n))}function ti(e){ee.current===e&&(B(Z),B(ee)),he.current===e&&(B(he),qa._currentValue=P)}var qu,Gr;function qn(e){if(qu===void 0)try{throw Error()}catch(n){var t=n.stack.trim().match(/\n( *(at )?)/);qu=t&&t[1]||"",Gr=-1)":-1a||g[l]!==R[a]){var D=` +`+g[l].replace(" at new "," at ");return e.displayName&&D.includes("")&&(D=D.replace("",e.displayName)),D}while(1<=l&&0<=a);break}}}finally{Yu=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?qn(n):""}function cp(e,t){switch(e.tag){case 26:case 27:case 5:return qn(e.type);case 16:return qn("Lazy");case 13:return e.child!==t&&t!==null?qn("Suspense Fallback"):qn("Suspense");case 19:return qn("SuspenseList");case 0:case 15:return ku(e.type,!1);case 11:return ku(e.type.render,!1);case 1:return ku(e.type,!0);case 31:return qn("Activity");default:return""}}function Vr(e){try{var t="",n=null;do t+=cp(e,n),n=e,e=e.return;while(e);return t}catch(l){return` +Error generating stack: `+l.message+` +`+l.stack}}var Xu=Object.prototype.hasOwnProperty,Gu=u.unstable_scheduleCallback,Vu=u.unstable_cancelCallback,rp=u.unstable_shouldYield,op=u.unstable_requestPaint,st=u.unstable_now,fp=u.unstable_getCurrentPriorityLevel,Qr=u.unstable_ImmediatePriority,Zr=u.unstable_UserBlockingPriority,ni=u.unstable_NormalPriority,dp=u.unstable_LowPriority,Kr=u.unstable_IdlePriority,hp=u.log,mp=u.unstable_setDisableYieldValue,Fl=null,ct=null;function dn(e){if(typeof hp=="function"&&mp(e),ct&&typeof ct.setStrictMode=="function")try{ct.setStrictMode(Fl,e)}catch{}}var rt=Math.clz32?Math.clz32:vp,pp=Math.log,yp=Math.LN2;function vp(e){return e>>>=0,e===0?32:31-(pp(e)/yp|0)|0}var li=256,ai=262144,ii=4194304;function Yn(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function ui(e,t,n){var l=e.pendingLanes;if(l===0)return 0;var a=0,s=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var h=l&134217727;return h!==0?(l=h&~s,l!==0?a=Yn(l):(o&=h,o!==0?a=Yn(o):n||(n=h&~e,n!==0&&(a=Yn(n))))):(h=l&~s,h!==0?a=Yn(h):o!==0?a=Yn(o):n||(n=l&~e,n!==0&&(a=Yn(n)))),a===0?0:t!==0&&t!==a&&(t&s)===0&&(s=a&-a,n=t&-t,s>=n||s===32&&(n&4194048)!==0)?t:a}function Pl(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function gp(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Jr(){var e=ii;return ii<<=1,(ii&62914560)===0&&(ii=4194304),e}function Qu(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Il(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function bp(e,t,n,l,a,s){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var h=e.entanglements,g=e.expirationTimes,R=e.hiddenUpdates;for(n=o&~n;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Ap=/[\n"\\]/g;function gt(e){return e.replace(Ap,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Fu(e,t,n,l,a,s,o,h){e.name="",o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?e.type=o:e.removeAttribute("type"),t!=null?o==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+vt(t)):e.value!==""+vt(t)&&(e.value=""+vt(t)):o!=="submit"&&o!=="reset"||e.removeAttribute("value"),t!=null?Pu(e,o,vt(t)):n!=null?Pu(e,o,vt(n)):l!=null&&e.removeAttribute("value"),a==null&&s!=null&&(e.defaultChecked=!!s),a!=null&&(e.checked=a&&typeof a!="function"&&typeof a!="symbol"),h!=null&&typeof h!="function"&&typeof h!="symbol"&&typeof h!="boolean"?e.name=""+vt(h):e.removeAttribute("name")}function so(e,t,n,l,a,s,o,h){if(s!=null&&typeof s!="function"&&typeof s!="symbol"&&typeof s!="boolean"&&(e.type=s),t!=null||n!=null){if(!(s!=="submit"&&s!=="reset"||t!=null)){Wu(e);return}n=n!=null?""+vt(n):"",t=t!=null?""+vt(t):n,h||t===e.value||(e.value=t),e.defaultValue=t}l=l??a,l=typeof l!="function"&&typeof l!="symbol"&&!!l,e.checked=h?e.checked:!!l,e.defaultChecked=!!l,o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"&&(e.name=o),Wu(e)}function Pu(e,t,n){t==="number"&&ri(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function hl(e,t,n,l){if(e=e.options,t){t={};for(var a=0;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ls=!1;if(Qt)try{var la={};Object.defineProperty(la,"passive",{get:function(){ls=!0}}),window.addEventListener("test",la,la),window.removeEventListener("test",la,la)}catch{ls=!1}var mn=null,as=null,fi=null;function po(){if(fi)return fi;var e,t=as,n=t.length,l,a="value"in mn?mn.value:mn.textContent,s=a.length;for(e=0;e=ua),xo=" ",_o=!1;function Eo(e,t){switch(e){case"keyup":return Ip.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function To(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var vl=!1;function ty(e,t){switch(e){case"compositionend":return To(t);case"keypress":return t.which!==32?null:(_o=!0,xo);case"textInput":return e=t.data,e===xo&&_o?null:e;default:return null}}function ny(e,t){if(vl)return e==="compositionend"||!rs&&Eo(e,t)?(e=po(),fi=as=mn=null,vl=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=l}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Do(n)}}function wo(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?wo(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Uo(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=ri(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=ri(e.document)}return t}function ds(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var oy=Qt&&"documentMode"in document&&11>=document.documentMode,gl=null,hs=null,oa=null,ms=!1;function Bo(e,t,n){var l=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;ms||gl==null||gl!==ri(l)||(l=gl,"selectionStart"in l&&ds(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),oa&&ra(oa,l)||(oa=l,l=au(hs,"onSelect"),0>=o,a-=o,Ut=1<<32-rt(t)+a|n<ne?(se=K,K=null):se=K.sibling;var fe=O(A,K,N[ne],w);if(fe===null){K===null&&(K=se);break}e&&K&&fe.alternate===null&&t(A,K),x=s(fe,x,ne),oe===null?J=fe:oe.sibling=fe,oe=fe,K=se}if(ne===N.length)return n(A,K),ce&&Kt(A,ne),J;if(K===null){for(;nene?(se=K,K=null):se=K.sibling;var Bn=O(A,K,fe.value,w);if(Bn===null){K===null&&(K=se);break}e&&K&&Bn.alternate===null&&t(A,K),x=s(Bn,x,ne),oe===null?J=Bn:oe.sibling=Bn,oe=Bn,K=se}if(fe.done)return n(A,K),ce&&Kt(A,ne),J;if(K===null){for(;!fe.done;ne++,fe=N.next())fe=U(A,fe.value,w),fe!==null&&(x=s(fe,x,ne),oe===null?J=fe:oe.sibling=fe,oe=fe);return ce&&Kt(A,ne),J}for(K=l(K);!fe.done;ne++,fe=N.next())fe=M(K,A,ne,fe.value,w),fe!==null&&(e&&fe.alternate!==null&&K.delete(fe.key===null?ne:fe.key),x=s(fe,x,ne),oe===null?J=fe:oe.sibling=fe,oe=fe);return e&&K.forEach(function(z0){return t(A,z0)}),ce&&Kt(A,ne),J}function Se(A,x,N,w){if(typeof N=="object"&&N!==null&&N.type===X&&N.key===null&&(N=N.props.children),typeof N=="object"&&N!==null){switch(N.$$typeof){case z:e:{for(var J=N.key;x!==null;){if(x.key===J){if(J=N.type,J===X){if(x.tag===7){n(A,x.sibling),w=a(x,N.props.children),w.return=A,A=w;break e}}else if(x.elementType===J||typeof J=="object"&&J!==null&&J.$$typeof===Te&&Fn(J)===x.type){n(A,x.sibling),w=a(x,N.props),ya(w,N),w.return=A,A=w;break e}n(A,x);break}else t(A,x);x=x.sibling}N.type===X?(w=Zn(N.props.children,A.mode,w,N.key),w.return=A,A=w):(w=xi(N.type,N.key,N.props,null,A.mode,w),ya(w,N),w.return=A,A=w)}return o(A);case L:e:{for(J=N.key;x!==null;){if(x.key===J)if(x.tag===4&&x.stateNode.containerInfo===N.containerInfo&&x.stateNode.implementation===N.implementation){n(A,x.sibling),w=a(x,N.children||[]),w.return=A,A=w;break e}else{n(A,x);break}else t(A,x);x=x.sibling}w=xs(N,A.mode,w),w.return=A,A=w}return o(A);case Te:return N=Fn(N),Se(A,x,N,w)}if(Rt(N))return Q(A,x,N,w);if(We(N)){if(J=We(N),typeof J!="function")throw Error(r(150));return N=J.call(N),W(A,x,N,w)}if(typeof N.then=="function")return Se(A,x,Ri(N),w);if(N.$$typeof===k)return Se(A,x,Ti(A,N),w);Oi(A,N)}return typeof N=="string"&&N!==""||typeof N=="number"||typeof N=="bigint"?(N=""+N,x!==null&&x.tag===6?(n(A,x.sibling),w=a(x,N),w.return=A,A=w):(n(A,x),w=Ss(N,A.mode,w),w.return=A,A=w),o(A)):n(A,x)}return function(A,x,N,w){try{pa=0;var J=Se(A,x,N,w);return Ol=null,J}catch(K){if(K===Rl||K===Ci)throw K;var oe=ft(29,K,null,A.mode);return oe.lanes=w,oe.return=A,oe}finally{}}}var In=uf(!0),sf=uf(!1),bn=!1;function js(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ws(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Sn(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function xn(e,t,n){var l=e.updateQueue;if(l===null)return null;if(l=l.shared,(de&2)!==0){var a=l.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),l.pending=t,t=Si(e),Go(e,null,n),t}return bi(e,l,t,n),Si(e)}function va(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194048)!==0)){var l=t.lanes;l&=e.pendingLanes,n|=l,t.lanes=n,Wr(e,n)}}function Us(e,t){var n=e.updateQueue,l=e.alternate;if(l!==null&&(l=l.updateQueue,n===l)){var a=null,s=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};s===null?a=s=o:s=s.next=o,n=n.next}while(n!==null);s===null?a=s=t:s=s.next=t}else a=s=t;n={baseState:l.baseState,firstBaseUpdate:a,lastBaseUpdate:s,shared:l.shared,callbacks:l.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Bs=!1;function ga(){if(Bs){var e=Nl;if(e!==null)throw e}}function ba(e,t,n,l){Bs=!1;var a=e.updateQueue;bn=!1;var s=a.firstBaseUpdate,o=a.lastBaseUpdate,h=a.shared.pending;if(h!==null){a.shared.pending=null;var g=h,R=g.next;g.next=null,o===null?s=R:o.next=R,o=g;var D=e.alternate;D!==null&&(D=D.updateQueue,h=D.lastBaseUpdate,h!==o&&(h===null?D.firstBaseUpdate=R:h.next=R,D.lastBaseUpdate=g))}if(s!==null){var U=a.baseState;o=0,D=R=g=null,h=s;do{var O=h.lane&-536870913,M=O!==h.lane;if(M?(ue&O)===O:(l&O)===O){O!==0&&O===Cl&&(Bs=!0),D!==null&&(D=D.next={lane:0,tag:h.tag,payload:h.payload,callback:null,next:null});e:{var Q=e,W=h;O=t;var Se=n;switch(W.tag){case 1:if(Q=W.payload,typeof Q=="function"){U=Q.call(Se,U,O);break e}U=Q;break e;case 3:Q.flags=Q.flags&-65537|128;case 0:if(Q=W.payload,O=typeof Q=="function"?Q.call(Se,U,O):Q,O==null)break e;U=S({},U,O);break e;case 2:bn=!0}}O=h.callback,O!==null&&(e.flags|=64,M&&(e.flags|=8192),M=a.callbacks,M===null?a.callbacks=[O]:M.push(O))}else M={lane:O,tag:h.tag,payload:h.payload,callback:h.callback,next:null},D===null?(R=D=M,g=U):D=D.next=M,o|=O;if(h=h.next,h===null){if(h=a.shared.pending,h===null)break;M=h,h=M.next,M.next=null,a.lastBaseUpdate=M,a.shared.pending=null}}while(!0);D===null&&(g=U),a.baseState=g,a.firstBaseUpdate=R,a.lastBaseUpdate=D,s===null&&(a.shared.lanes=0),Cn|=o,e.lanes=o,e.memoizedState=U}}function cf(e,t){if(typeof e!="function")throw Error(r(191,e));e.call(t)}function rf(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;es?s:8;var o=j.T,h={};j.T=h,tc(e,!1,t,n);try{var g=a(),R=j.S;if(R!==null&&R(h,g),g!==null&&typeof g=="object"&&typeof g.then=="function"){var D=by(g,l);_a(e,t,D,yt(e))}else _a(e,t,l,yt(e))}catch(U){_a(e,t,{then:function(){},status:"rejected",reason:U},yt())}finally{G.p=s,o!==null&&h.types!==null&&(o.types=h.types),j.T=o}}function Ay(){}function Is(e,t,n,l){if(e.tag!==5)throw Error(r(476));var a=kf(e).queue;Yf(e,a,t,P,n===null?Ay:function(){return Xf(e),n(l)})}function kf(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:P,baseState:P,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ft,lastRenderedState:P},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ft,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Xf(e){var t=kf(e);t.next===null&&(t=e.alternate.memoizedState),_a(e,t.next.queue,{},yt())}function ec(){return Ke(qa)}function Gf(){return we().memoizedState}function Vf(){return we().memoizedState}function Cy(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=yt();e=Sn(n);var l=xn(t,e,n);l!==null&&(ut(l,t,n),va(l,t,n)),t={cache:Os()},e.payload=t;return}t=t.return}}function Ny(e,t,n){var l=yt();n={lane:l,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},qi(e)?Zf(t,n):(n=gs(e,t,n,l),n!==null&&(ut(n,e,l),Kf(n,t,l)))}function Qf(e,t,n){var l=yt();_a(e,t,n,l)}function _a(e,t,n,l){var a={lane:l,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(qi(e))Zf(t,a);else{var s=e.alternate;if(e.lanes===0&&(s===null||s.lanes===0)&&(s=t.lastRenderedReducer,s!==null))try{var o=t.lastRenderedState,h=s(o,n);if(a.hasEagerState=!0,a.eagerState=h,ot(h,o))return bi(e,t,a,0),Ee===null&&gi(),!1}catch{}finally{}if(n=gs(e,t,a,l),n!==null)return ut(n,e,l),Kf(n,t,l),!0}return!1}function tc(e,t,n,l){if(l={lane:2,revertLane:jc(),gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},qi(e)){if(t)throw Error(r(479))}else t=gs(e,n,l,2),t!==null&&ut(t,e,2)}function qi(e){var t=e.alternate;return e===te||t!==null&&t===te}function Zf(e,t){Ml=Di=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Kf(e,t,n){if((n&4194048)!==0){var l=t.lanes;l&=e.pendingLanes,n|=l,t.lanes=n,Wr(e,n)}}var Ea={readContext:Ke,use:Ui,useCallback:ze,useContext:ze,useEffect:ze,useImperativeHandle:ze,useLayoutEffect:ze,useInsertionEffect:ze,useMemo:ze,useReducer:ze,useRef:ze,useState:ze,useDebugValue:ze,useDeferredValue:ze,useTransition:ze,useSyncExternalStore:ze,useId:ze,useHostTransitionStatus:ze,useFormState:ze,useActionState:ze,useOptimistic:ze,useMemoCache:ze,useCacheRefresh:ze};Ea.useEffectEvent=ze;var Jf={readContext:Ke,use:Ui,useCallback:function(e,t){return Ie().memoizedState=[e,t===void 0?null:t],e},useContext:Ke,useEffect:Mf,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Li(4194308,4,Uf.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Li(4194308,4,e,t)},useInsertionEffect:function(e,t){Li(4,2,e,t)},useMemo:function(e,t){var n=Ie();t=t===void 0?null:t;var l=e();if(el){dn(!0);try{e()}finally{dn(!1)}}return n.memoizedState=[l,t],l},useReducer:function(e,t,n){var l=Ie();if(n!==void 0){var a=n(t);if(el){dn(!0);try{n(t)}finally{dn(!1)}}}else a=t;return l.memoizedState=l.baseState=a,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:a},l.queue=e,e=e.dispatch=Ny.bind(null,te,e),[l.memoizedState,e]},useRef:function(e){var t=Ie();return e={current:e},t.memoizedState=e},useState:function(e){e=Js(e);var t=e.queue,n=Qf.bind(null,te,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Fs,useDeferredValue:function(e,t){var n=Ie();return Ps(n,e,t)},useTransition:function(){var e=Js(!1);return e=Yf.bind(null,te,e.queue,!0,!1),Ie().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var l=te,a=Ie();if(ce){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Ee===null)throw Error(r(349));(ue&127)!==0||pf(l,t,n)}a.memoizedState=n;var s={value:n,getSnapshot:t};return a.queue=s,Mf(vf.bind(null,l,s,e),[e]),l.flags|=2048,jl(9,{destroy:void 0},yf.bind(null,l,s,n,t),null),n},useId:function(){var e=Ie(),t=Ee.identifierPrefix;if(ce){var n=Bt,l=Ut;n=(l&~(1<<32-rt(l)-1)).toString(32)+n,t="_"+t+"R_"+n,n=ji++,0<\/script>",s=s.removeChild(s.firstChild);break;case"select":s=typeof l.is=="string"?o.createElement("select",{is:l.is}):o.createElement("select"),l.multiple?s.multiple=!0:l.size&&(s.size=l.size);break;default:s=typeof l.is=="string"?o.createElement(a,{is:l.is}):o.createElement(a)}}s[Qe]=t,s[et]=l;e:for(o=t.child;o!==null;){if(o.tag===5||o.tag===6)s.appendChild(o.stateNode);else if(o.tag!==4&&o.tag!==27&&o.child!==null){o.child.return=o,o=o.child;continue}if(o===t)break e;for(;o.sibling===null;){if(o.return===null||o.return===t)break e;o=o.return}o.sibling.return=o.return,o=o.sibling}t.stateNode=s;e:switch($e(s,a,l),a){case"button":case"input":case"select":case"textarea":l=!!l.autoFocus;break e;case"img":l=!0;break e;default:l=!1}l&&It(t)}}return Ce(t),pc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==l&&It(t);else{if(typeof l!="string"&&t.stateNode===null)throw Error(r(166));if(e=le.current,Tl(t)){if(e=t.stateNode,n=t.memoizedProps,l=null,a=Ze,a!==null)switch(a.tag){case 27:case 5:l=a.memoizedProps}e[Qe]=t,e=!!(e.nodeValue===n||l!==null&&l.suppressHydrationWarning===!0||hh(e.nodeValue,n)),e||vn(t,!0)}else e=iu(e).createTextNode(l),e[Qe]=t,t.stateNode=e}return Ce(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(l=Tl(t),n!==null){if(e===null){if(!l)throw Error(r(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(r(557));e[Qe]=t}else Kn(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Ce(t),e=!1}else n=As(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(ht(t),t):(ht(t),null);if((t.flags&128)!==0)throw Error(r(558))}return Ce(t),null;case 13:if(l=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Tl(t),l!==null&&l.dehydrated!==null){if(e===null){if(!a)throw Error(r(318));if(a=t.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(r(317));a[Qe]=t}else Kn(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Ce(t),a=!1}else a=As(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(ht(t),t):(ht(t),null)}return ht(t),(t.flags&128)!==0?(t.lanes=n,t):(n=l!==null,e=e!==null&&e.memoizedState!==null,n&&(l=t.child,a=null,l.alternate!==null&&l.alternate.memoizedState!==null&&l.alternate.memoizedState.cachePool!==null&&(a=l.alternate.memoizedState.cachePool.pool),s=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(s=l.memoizedState.cachePool.pool),s!==a&&(l.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Vi(t,t.updateQueue),Ce(t),null);case 4:return De(),e===null&&Lc(t.stateNode.containerInfo),Ce(t),null;case 10:return $t(t.type),Ce(t),null;case 19:if(B(je),l=t.memoizedState,l===null)return Ce(t),null;if(a=(t.flags&128)!==0,s=l.rendering,s===null)if(a)Aa(l,!1);else{if(Me!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(s=Mi(e),s!==null){for(t.flags|=128,Aa(l,!1),e=s.updateQueue,t.updateQueue=e,Vi(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)Vo(n,e),n=n.sibling;return V(je,je.current&1|2),ce&&Kt(t,l.treeForkCount),t.child}e=e.sibling}l.tail!==null&&st()>$i&&(t.flags|=128,a=!0,Aa(l,!1),t.lanes=4194304)}else{if(!a)if(e=Mi(s),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Vi(t,e),Aa(l,!0),l.tail===null&&l.tailMode==="hidden"&&!s.alternate&&!ce)return Ce(t),null}else 2*st()-l.renderingStartTime>$i&&n!==536870912&&(t.flags|=128,a=!0,Aa(l,!1),t.lanes=4194304);l.isBackwards?(s.sibling=t.child,t.child=s):(e=l.last,e!==null?e.sibling=s:t.child=s,l.last=s)}return l.tail!==null?(e=l.tail,l.rendering=e,l.tail=e.sibling,l.renderingStartTime=st(),e.sibling=null,n=je.current,V(je,a?n&1|2:n&1),ce&&Kt(t,l.treeForkCount),e):(Ce(t),null);case 22:case 23:return ht(t),Hs(),l=t.memoizedState!==null,e!==null?e.memoizedState!==null!==l&&(t.flags|=8192):l&&(t.flags|=8192),l?(n&536870912)!==0&&(t.flags&128)===0&&(Ce(t),t.subtreeFlags&6&&(t.flags|=8192)):Ce(t),n=t.updateQueue,n!==null&&Vi(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),l=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(l=t.memoizedState.cachePool.pool),l!==n&&(t.flags|=2048),e!==null&&B(Wn),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),$t(Be),Ce(t),null;case 25:return null;case 30:return null}throw Error(r(156,t.tag))}function Dy(e,t){switch(Es(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return $t(Be),De(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return ti(t),null;case 31:if(t.memoizedState!==null){if(ht(t),t.alternate===null)throw Error(r(340));Kn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(ht(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Kn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return B(je),null;case 4:return De(),null;case 10:return $t(t.type),null;case 22:case 23:return ht(t),Hs(),e!==null&&B(Wn),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return $t(Be),null;case 25:return null;default:return null}}function gd(e,t){switch(Es(t),t.tag){case 3:$t(Be),De();break;case 26:case 27:case 5:ti(t);break;case 4:De();break;case 31:t.memoizedState!==null&&ht(t);break;case 13:ht(t);break;case 19:B(je);break;case 10:$t(t.type);break;case 22:case 23:ht(t),Hs(),e!==null&&B(Wn);break;case 24:$t(Be)}}function Ca(e,t){try{var n=t.updateQueue,l=n!==null?n.lastEffect:null;if(l!==null){var a=l.next;n=a;do{if((n.tag&e)===e){l=void 0;var s=n.create,o=n.inst;l=s(),o.destroy=l}n=n.next}while(n!==a)}}catch(h){pe(t,t.return,h)}}function Tn(e,t,n){try{var l=t.updateQueue,a=l!==null?l.lastEffect:null;if(a!==null){var s=a.next;l=s;do{if((l.tag&e)===e){var o=l.inst,h=o.destroy;if(h!==void 0){o.destroy=void 0,a=t;var g=n,R=h;try{R()}catch(D){pe(a,g,D)}}}l=l.next}while(l!==s)}}catch(D){pe(t,t.return,D)}}function bd(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{rf(t,n)}catch(l){pe(e,e.return,l)}}}function Sd(e,t,n){n.props=tl(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(l){pe(e,t,l)}}function Na(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var l=e.stateNode;break;case 30:l=e.stateNode;break;default:l=e.stateNode}typeof n=="function"?e.refCleanup=n(l):n.current=l}}catch(a){pe(e,t,a)}}function Lt(e,t){var n=e.ref,l=e.refCleanup;if(n!==null)if(typeof l=="function")try{l()}catch(a){pe(e,t,a)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n=="function")try{n(null)}catch(a){pe(e,t,a)}else n.current=null}function xd(e){var t=e.type,n=e.memoizedProps,l=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":n.autoFocus&&l.focus();break e;case"img":n.src?l.src=n.src:n.srcSet&&(l.srcset=n.srcSet)}}catch(a){pe(e,e.return,a)}}function yc(e,t,n){try{var l=e.stateNode;e0(l,e.type,n,t),l[et]=t}catch(a){pe(e,e.return,a)}}function _d(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Mn(e.type)||e.tag===4}function vc(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||_d(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Mn(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function gc(e,t,n){var l=e.tag;if(l===5||l===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Vt));else if(l!==4&&(l===27&&Mn(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(gc(e,t,n),e=e.sibling;e!==null;)gc(e,t,n),e=e.sibling}function Qi(e,t,n){var l=e.tag;if(l===5||l===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(l!==4&&(l===27&&Mn(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Qi(e,t,n),e=e.sibling;e!==null;)Qi(e,t,n),e=e.sibling}function Ed(e){var t=e.stateNode,n=e.memoizedProps;try{for(var l=e.type,a=t.attributes;a.length;)t.removeAttributeNode(a[0]);$e(t,l,n),t[Qe]=e,t[et]=n}catch(s){pe(e,e.return,s)}}var en=!1,qe=!1,bc=!1,Td=typeof WeakSet=="function"?WeakSet:Set,Ve=null;function jy(e,t){if(e=e.containerInfo,Yc=du,e=Uo(e),ds(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var l=n.getSelection&&n.getSelection();if(l&&l.rangeCount!==0){n=l.anchorNode;var a=l.anchorOffset,s=l.focusNode;l=l.focusOffset;try{n.nodeType,s.nodeType}catch{n=null;break e}var o=0,h=-1,g=-1,R=0,D=0,U=e,O=null;t:for(;;){for(var M;U!==n||a!==0&&U.nodeType!==3||(h=o+a),U!==s||l!==0&&U.nodeType!==3||(g=o+l),U.nodeType===3&&(o+=U.nodeValue.length),(M=U.firstChild)!==null;)O=U,U=M;for(;;){if(U===e)break t;if(O===n&&++R===a&&(h=o),O===s&&++D===l&&(g=o),(M=U.nextSibling)!==null)break;U=O,O=U.parentNode}U=M}n=h===-1||g===-1?null:{start:h,end:g}}else n=null}n=n||{start:0,end:0}}else n=null;for(kc={focusedElem:e,selectionRange:n},du=!1,Ve=t;Ve!==null;)if(t=Ve,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,Ve=e;else for(;Ve!==null;){switch(t=Ve,s=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(n=0;n title"))),$e(s,l,n),s[Qe]=e,Ge(s),l=s;break e;case"link":var o=zh("link","href",a).get(l+(n.href||""));if(o){for(var h=0;hSe&&(o=Se,Se=W,W=o);var A=jo(h,W),x=jo(h,Se);if(A&&x&&(M.rangeCount!==1||M.anchorNode!==A.node||M.anchorOffset!==A.offset||M.focusNode!==x.node||M.focusOffset!==x.offset)){var N=U.createRange();N.setStart(A.node,A.offset),M.removeAllRanges(),W>Se?(M.addRange(N),M.extend(x.node,x.offset)):(N.setEnd(x.node,x.offset),M.addRange(N))}}}}for(U=[],M=h;M=M.parentNode;)M.nodeType===1&&U.push({element:M,left:M.scrollLeft,top:M.scrollTop});for(typeof h.focus=="function"&&h.focus(),h=0;hn?32:n,j.T=null,n=Cc,Cc=null;var s=Rn,o=un;if(ke=0,Hl=Rn=null,un=0,(de&6)!==0)throw Error(r(331));var h=de;if(de|=4,Ud(s.current),Dd(s,s.current,o,n),de=h,ja(0,!1),ct&&typeof ct.onPostCommitFiberRoot=="function")try{ct.onPostCommitFiberRoot(Fl,s)}catch{}return!0}finally{G.p=a,j.T=l,Id(e,t)}}function th(e,t,n){t=St(n,t),t=ic(e.stateNode,t,2),e=xn(e,t,2),e!==null&&(Il(e,2),Ht(e))}function pe(e,t,n){if(e.tag===3)th(e,e,n);else for(;t!==null;){if(t.tag===3){th(t,e,n);break}else if(t.tag===1){var l=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof l.componentDidCatch=="function"&&(Nn===null||!Nn.has(l))){e=St(n,e),n=nd(2),l=xn(t,n,2),l!==null&&(ld(n,l,t,e),Il(l,2),Ht(l));break}}t=t.return}}function zc(e,t,n){var l=e.pingCache;if(l===null){l=e.pingCache=new By;var a=new Set;l.set(t,a)}else a=l.get(t),a===void 0&&(a=new Set,l.set(t,a));a.has(n)||(_c=!0,a.add(n),e=ky.bind(null,e,t,n),t.then(e,e))}function ky(e,t,n){var l=e.pingCache;l!==null&&l.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Ee===e&&(ue&n)===n&&(Me===4||Me===3&&(ue&62914560)===ue&&300>st()-Ji?(de&2)===0&&ql(e,0):Ec|=n,Ll===ue&&(Ll=0)),Ht(e)}function nh(e,t){t===0&&(t=Jr()),e=Qn(e,t),e!==null&&(Il(e,t),Ht(e))}function Xy(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),nh(e,n)}function Gy(e,t){var n=0;switch(e.tag){case 31:case 13:var l=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:l=e.stateNode;break;case 22:l=e.stateNode._retryCache;break;default:throw Error(r(314))}l!==null&&l.delete(t),nh(e,n)}function Vy(e,t){return Gu(e,t)}var tu=null,kl=null,Mc=!1,nu=!1,Dc=!1,zn=0;function Ht(e){e!==kl&&e.next===null&&(kl===null?tu=kl=e:kl=kl.next=e),nu=!0,Mc||(Mc=!0,Zy())}function ja(e,t){if(!Dc&&nu){Dc=!0;do for(var n=!1,l=tu;l!==null;){if(e!==0){var a=l.pendingLanes;if(a===0)var s=0;else{var o=l.suspendedLanes,h=l.pingedLanes;s=(1<<31-rt(42|e)+1)-1,s&=a&~(o&~h),s=s&201326741?s&201326741|1:s?s|2:0}s!==0&&(n=!0,uh(l,s))}else s=ue,s=ui(l,l===Ee?s:0,l.cancelPendingCommit!==null||l.timeoutHandle!==-1),(s&3)===0||Pl(l,s)||(n=!0,uh(l,s));l=l.next}while(n);Dc=!1}}function Qy(){lh()}function lh(){nu=Mc=!1;var e=0;zn!==0&&n0()&&(e=zn);for(var t=st(),n=null,l=tu;l!==null;){var a=l.next,s=ah(l,t);s===0?(l.next=null,n===null?tu=a:n.next=a,a===null&&(kl=n)):(n=l,(e!==0||(s&3)!==0)&&(nu=!0)),l=a}ke!==0&&ke!==5||ja(e),zn!==0&&(zn=0)}function ah(e,t){for(var n=e.suspendedLanes,l=e.pingedLanes,a=e.expirationTimes,s=e.pendingLanes&-62914561;0h)break;var D=g.transferSize,U=g.initiatorType;D&&mh(U)&&(g=g.responseEnd,o+=D*(g"u"?null:document;function Ch(e,t,n){var l=Xl;if(l&&typeof t=="string"&&t){var a=gt(t);a='link[rel="'+e+'"][href="'+a+'"]',typeof n=="string"&&(a+='[crossorigin="'+n+'"]'),Ah.has(a)||(Ah.add(a),e={rel:e,crossOrigin:n,href:t},l.querySelector(a)===null&&(t=l.createElement("link"),$e(t,"link",e),Ge(t),l.head.appendChild(t)))}}function f0(e){sn.D(e),Ch("dns-prefetch",e,null)}function d0(e,t){sn.C(e,t),Ch("preconnect",e,t)}function h0(e,t,n){sn.L(e,t,n);var l=Xl;if(l&&e&&t){var a='link[rel="preload"][as="'+gt(t)+'"]';t==="image"&&n&&n.imageSrcSet?(a+='[imagesrcset="'+gt(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(a+='[imagesizes="'+gt(n.imageSizes)+'"]')):a+='[href="'+gt(e)+'"]';var s=a;switch(t){case"style":s=Gl(e);break;case"script":s=Vl(e)}Ct.has(s)||(e=S({rel:"preload",href:t==="image"&&n&&n.imageSrcSet?void 0:e,as:t},n),Ct.set(s,e),l.querySelector(a)!==null||t==="style"&&l.querySelector(La(s))||t==="script"&&l.querySelector(Ha(s))||(t=l.createElement("link"),$e(t,"link",e),Ge(t),l.head.appendChild(t)))}}function m0(e,t){sn.m(e,t);var n=Xl;if(n&&e){var l=t&&typeof t.as=="string"?t.as:"script",a='link[rel="modulepreload"][as="'+gt(l)+'"][href="'+gt(e)+'"]',s=a;switch(l){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":s=Vl(e)}if(!Ct.has(s)&&(e=S({rel:"modulepreload",href:e},t),Ct.set(s,e),n.querySelector(a)===null)){switch(l){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(n.querySelector(Ha(s)))return}l=n.createElement("link"),$e(l,"link",e),Ge(l),n.head.appendChild(l)}}}function p0(e,t,n){sn.S(e,t,n);var l=Xl;if(l&&e){var a=fl(l).hoistableStyles,s=Gl(e);t=t||"default";var o=a.get(s);if(!o){var h={loading:0,preload:null};if(o=l.querySelector(La(s)))h.loading=5;else{e=S({rel:"stylesheet",href:e,"data-precedence":t},n),(n=Ct.get(s))&&Jc(e,n);var g=o=l.createElement("link");Ge(g),$e(g,"link",e),g._p=new Promise(function(R,D){g.onload=R,g.onerror=D}),g.addEventListener("load",function(){h.loading|=1}),g.addEventListener("error",function(){h.loading|=2}),h.loading|=4,su(o,t,l)}o={type:"stylesheet",instance:o,count:1,state:h},a.set(s,o)}}}function y0(e,t){sn.X(e,t);var n=Xl;if(n&&e){var l=fl(n).hoistableScripts,a=Vl(e),s=l.get(a);s||(s=n.querySelector(Ha(a)),s||(e=S({src:e,async:!0},t),(t=Ct.get(a))&&$c(e,t),s=n.createElement("script"),Ge(s),$e(s,"link",e),n.head.appendChild(s)),s={type:"script",instance:s,count:1,state:null},l.set(a,s))}}function v0(e,t){sn.M(e,t);var n=Xl;if(n&&e){var l=fl(n).hoistableScripts,a=Vl(e),s=l.get(a);s||(s=n.querySelector(Ha(a)),s||(e=S({src:e,async:!0,type:"module"},t),(t=Ct.get(a))&&$c(e,t),s=n.createElement("script"),Ge(s),$e(s,"link",e),n.head.appendChild(s)),s={type:"script",instance:s,count:1,state:null},l.set(a,s))}}function Nh(e,t,n,l){var a=(a=le.current)?uu(a):null;if(!a)throw Error(r(446));switch(e){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(t=Gl(n.href),n=fl(a).hoistableStyles,l=n.get(t),l||(l={type:"style",instance:null,count:0,state:null},n.set(t,l)),l):{type:"void",instance:null,count:0,state:null};case"link":if(n.rel==="stylesheet"&&typeof n.href=="string"&&typeof n.precedence=="string"){e=Gl(n.href);var s=fl(a).hoistableStyles,o=s.get(e);if(o||(a=a.ownerDocument||a,o={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},s.set(e,o),(s=a.querySelector(La(e)))&&!s._p&&(o.instance=s,o.state.loading=5),Ct.has(e)||(n={rel:"preload",as:"style",href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},Ct.set(e,n),s||g0(a,e,n,o.state))),t&&l===null)throw Error(r(528,""));return o}if(t&&l!==null)throw Error(r(529,""));return null;case"script":return t=n.async,n=n.src,typeof n=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=Vl(n),n=fl(a).hoistableScripts,l=n.get(t),l||(l={type:"script",instance:null,count:0,state:null},n.set(t,l)),l):{type:"void",instance:null,count:0,state:null};default:throw Error(r(444,e))}}function Gl(e){return'href="'+gt(e)+'"'}function La(e){return'link[rel="stylesheet"]['+e+"]"}function Rh(e){return S({},e,{"data-precedence":e.precedence,precedence:null})}function g0(e,t,n,l){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?l.loading=1:(t=e.createElement("link"),l.preload=t,t.addEventListener("load",function(){return l.loading|=1}),t.addEventListener("error",function(){return l.loading|=2}),$e(t,"link",n),Ge(t),e.head.appendChild(t))}function Vl(e){return'[src="'+gt(e)+'"]'}function Ha(e){return"script[async]"+e}function Oh(e,t,n){if(t.count++,t.instance===null)switch(t.type){case"style":var l=e.querySelector('style[data-href~="'+gt(n.href)+'"]');if(l)return t.instance=l,Ge(l),l;var a=S({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return l=(e.ownerDocument||e).createElement("style"),Ge(l),$e(l,"style",a),su(l,n.precedence,e),t.instance=l;case"stylesheet":a=Gl(n.href);var s=e.querySelector(La(a));if(s)return t.state.loading|=4,t.instance=s,Ge(s),s;l=Rh(n),(a=Ct.get(a))&&Jc(l,a),s=(e.ownerDocument||e).createElement("link"),Ge(s);var o=s;return o._p=new Promise(function(h,g){o.onload=h,o.onerror=g}),$e(s,"link",l),t.state.loading|=4,su(s,n.precedence,e),t.instance=s;case"script":return s=Vl(n.src),(a=e.querySelector(Ha(s)))?(t.instance=a,Ge(a),a):(l=n,(a=Ct.get(s))&&(l=S({},n),$c(l,a)),e=e.ownerDocument||e,a=e.createElement("script"),Ge(a),$e(a,"link",l),e.head.appendChild(a),t.instance=a);case"void":return null;default:throw Error(r(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(l=t.instance,t.state.loading|=4,su(l,n.precedence,e));return t.instance}function su(e,t,n){for(var l=n.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),a=l.length?l[l.length-1]:null,s=a,o=0;o title"):null)}function b0(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Dh(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function S0(e,t,n,l){if(n.type==="stylesheet"&&(typeof l.media!="string"||matchMedia(l.media).matches!==!1)&&(n.state.loading&4)===0){if(n.instance===null){var a=Gl(l.href),s=t.querySelector(La(a));if(s){t=s._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=ru.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=s,Ge(s);return}s=t.ownerDocument||t,l=Rh(l),(a=Ct.get(a))&&Jc(l,a),s=s.createElement("link"),Ge(s);var o=s;o._p=new Promise(function(h,g){o.onload=h,o.onerror=g}),$e(s,"link",l),n.instance=s}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&(n.state.loading&3)===0&&(e.count++,n=ru.bind(e),t.addEventListener("load",n),t.addEventListener("error",n))}}var Wc=0;function x0(e,t){return e.stylesheets&&e.count===0&&fu(e,e.stylesheets),0Wc?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(l),clearTimeout(a)}}:null}function ru(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)fu(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var ou=null;function fu(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,ou=new Map,t.forEach(_0,e),ou=null,ru.call(e))}function _0(e,t){if(!(t.state.loading&4)){var n=ou.get(e);if(n)var l=n.get(null);else{n=new Map,ou.set(e,n);for(var a=e.querySelectorAll("link[data-precedence],style[data-precedence]"),s=0;s"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(u)}catch(i){console.error(i)}}return u(),ir.exports=H0(),ir.exports}var Y0=q0();var nm="popstate";function k0(u={}){function i(r,f){let{pathname:d,search:m,hash:b}=r.location;return yr("",{pathname:d,search:m,hash:b},f.state&&f.state.usr||null,f.state&&f.state.key||"default")}function c(r,f){return typeof f=="string"?f:Wa(f)}return G0(i,c,null,u)}function Oe(u,i){if(u===!1||u===null||typeof u>"u")throw new Error(i)}function jt(u,i){if(!u){typeof console<"u"&&console.warn(i);try{throw new Error(i)}catch{}}}function X0(){return Math.random().toString(36).substring(2,10)}function lm(u,i){return{usr:u.state,key:u.key,idx:i}}function yr(u,i,c=null,r){return{pathname:typeof u=="string"?u:u.pathname,search:"",hash:"",...typeof i=="string"?Kl(i):i,state:c,key:i&&i.key||r||X0()}}function Wa({pathname:u="/",search:i="",hash:c=""}){return i&&i!=="?"&&(u+=i.charAt(0)==="?"?i:"?"+i),c&&c!=="#"&&(u+=c.charAt(0)==="#"?c:"#"+c),u}function Kl(u){let i={};if(u){let c=u.indexOf("#");c>=0&&(i.hash=u.substring(c),u=u.substring(0,c));let r=u.indexOf("?");r>=0&&(i.search=u.substring(r),u=u.substring(0,r)),u&&(i.pathname=u)}return i}function G0(u,i,c,r={}){let{window:f=document.defaultView,v5Compat:d=!1}=r,m=f.history,b="POP",y=null,v=T();v==null&&(v=0,m.replaceState({...m.state,idx:v},""));function T(){return(m.state||{idx:null}).idx}function S(){b="POP";let q=T(),H=q==null?null:q-v;v=q,y&&y({action:b,location:X.location,delta:H})}function C(q,H){b="PUSH";let Y=yr(X.location,q,H);v=T()+1;let k=lm(Y,v),F=X.createHref(Y);try{m.pushState(k,"",F)}catch(ye){if(ye instanceof DOMException&&ye.name==="DataCloneError")throw ye;f.location.assign(F)}d&&y&&y({action:b,location:X.location,delta:1})}function z(q,H){b="REPLACE";let Y=yr(X.location,q,H);v=T();let k=lm(Y,v),F=X.createHref(Y);m.replaceState(k,"",F),d&&y&&y({action:b,location:X.location,delta:0})}function L(q){return V0(q)}let X={get action(){return b},get location(){return u(f,m)},listen(q){if(y)throw new Error("A history only accepts one active listener");return f.addEventListener(nm,S),y=q,()=>{f.removeEventListener(nm,S),y=null}},createHref(q){return i(f,q)},createURL:L,encodeLocation(q){let H=L(q);return{pathname:H.pathname,search:H.search,hash:H.hash}},push:C,replace:z,go(q){return m.go(q)}};return X}function V0(u,i=!1){let c="http://localhost";typeof window<"u"&&(c=window.location.origin!=="null"?window.location.origin:window.location.href),Oe(c,"No window.location.(origin|href) available to create URL");let r=typeof u=="string"?u:Wa(u);return r=r.replace(/ $/,"%20"),!i&&r.startsWith("//")&&(r=c+r),new URL(r,c)}function _m(u,i,c="/"){return Q0(u,i,c,!1)}function Q0(u,i,c,r){let f=typeof i=="string"?Kl(i):i,d=on(f.pathname||"/",c);if(d==null)return null;let m=Em(u);Z0(m);let b=null;for(let y=0;b==null&&y{let T={relativePath:v===void 0?m.path||"":v,caseSensitive:m.caseSensitive===!0,childrenIndex:b,route:m};if(T.relativePath.startsWith("/")){if(!T.relativePath.startsWith(r)&&y)return;Oe(T.relativePath.startsWith(r),`Absolute route path "${T.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),T.relativePath=T.relativePath.slice(r.length)}let S=rn([r,T.relativePath]),C=c.concat(T);m.children&&m.children.length>0&&(Oe(m.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${S}".`),Em(m.children,i,C,S,y)),!(m.path==null&&!m.index)&&i.push({path:S,score:I0(S,m.index),routesMeta:C})};return u.forEach((m,b)=>{if(m.path===""||!m.path?.includes("?"))d(m,b);else for(let y of Tm(m.path))d(m,b,!0,y)}),i}function Tm(u){let i=u.split("/");if(i.length===0)return[];let[c,...r]=i,f=c.endsWith("?"),d=c.replace(/\?$/,"");if(r.length===0)return f?[d,""]:[d];let m=Tm(r.join("/")),b=[];return b.push(...m.map(y=>y===""?d:[d,y].join("/"))),f&&b.push(...m),b.map(y=>u.startsWith("/")&&y===""?"/":y)}function Z0(u){u.sort((i,c)=>i.score!==c.score?c.score-i.score:ev(i.routesMeta.map(r=>r.childrenIndex),c.routesMeta.map(r=>r.childrenIndex)))}var K0=/^:[\w-]+$/,J0=3,$0=2,W0=1,F0=10,P0=-2,am=u=>u==="*";function I0(u,i){let c=u.split("/"),r=c.length;return c.some(am)&&(r+=P0),i&&(r+=$0),c.filter(f=>!am(f)).reduce((f,d)=>f+(K0.test(d)?J0:d===""?W0:F0),r)}function ev(u,i){return u.length===i.length&&u.slice(0,-1).every((r,f)=>r===i[f])?u[u.length-1]-i[i.length-1]:0}function tv(u,i,c=!1){let{routesMeta:r}=u,f={},d="/",m=[];for(let b=0;b{if(T==="*"){let L=b[C]||"";m=d.slice(0,d.length-L.length).replace(/(.)\/+$/,"$1")}const z=b[C];return S&&!z?v[T]=void 0:v[T]=(z||"").replace(/%2F/g,"/"),v},{}),pathname:d,pathnameBase:m,pattern:u}}function nv(u,i=!1,c=!0){jt(u==="*"||!u.endsWith("*")||u.endsWith("/*"),`Route path "${u}" will be treated as if it were "${u.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${u.replace(/\*$/,"/*")}".`);let r=[],f="^"+u.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(m,b,y)=>(r.push({paramName:b,isOptional:y!=null}),y?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return u.endsWith("*")?(r.push({paramName:"*"}),f+=u==="*"||u==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):c?f+="\\/*$":u!==""&&u!=="/"&&(f+="(?:(?=\\/|$))"),[new RegExp(f,i?void 0:"i"),r]}function lv(u){try{return u.split("/").map(i=>decodeURIComponent(i).replace(/\//g,"%2F")).join("/")}catch(i){return jt(!1,`The URL path "${u}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${i}).`),u}}function on(u,i){if(i==="/")return u;if(!u.toLowerCase().startsWith(i.toLowerCase()))return null;let c=i.endsWith("/")?i.length-1:i.length,r=u.charAt(c);return r&&r!=="/"?null:u.slice(c)||"/"}var av=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,iv=u=>av.test(u);function uv(u,i="/"){let{pathname:c,search:r="",hash:f=""}=typeof u=="string"?Kl(u):u,d;if(c)if(iv(c))d=c;else{if(c.includes("//")){let m=c;c=c.replace(/\/\/+/g,"/"),jt(!1,`Pathnames cannot have embedded double slashes - normalizing ${m} -> ${c}`)}c.startsWith("/")?d=im(c.substring(1),"/"):d=im(c,i)}else d=i;return{pathname:d,search:rv(r),hash:ov(f)}}function im(u,i){let c=i.replace(/\/+$/,"").split("/");return u.split("/").forEach(f=>{f===".."?c.length>1&&c.pop():f!=="."&&c.push(f)}),c.length>1?c.join("/"):"/"}function rr(u,i,c,r){return`Cannot include a '${u}' character in a manually specified \`to.${i}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${c}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function sv(u){return u.filter((i,c)=>c===0||i.route.path&&i.route.path.length>0)}function Am(u){let i=sv(u);return i.map((c,r)=>r===i.length-1?c.pathname:c.pathnameBase)}function Cm(u,i,c,r=!1){let f;typeof u=="string"?f=Kl(u):(f={...u},Oe(!f.pathname||!f.pathname.includes("?"),rr("?","pathname","search",f)),Oe(!f.pathname||!f.pathname.includes("#"),rr("#","pathname","hash",f)),Oe(!f.search||!f.search.includes("#"),rr("#","search","hash",f)));let d=u===""||f.pathname==="",m=d?"/":f.pathname,b;if(m==null)b=c;else{let S=i.length-1;if(!r&&m.startsWith("..")){let C=m.split("/");for(;C[0]==="..";)C.shift(),S-=1;f.pathname=C.join("/")}b=S>=0?i[S]:"/"}let y=uv(f,b),v=m&&m!=="/"&&m.endsWith("/"),T=(d||m===".")&&c.endsWith("/");return!y.pathname.endsWith("/")&&(v||T)&&(y.pathname+="/"),y}var rn=u=>u.join("/").replace(/\/\/+/g,"/"),cv=u=>u.replace(/\/+$/,"").replace(/^\/*/,"/"),rv=u=>!u||u==="?"?"":u.startsWith("?")?u:"?"+u,ov=u=>!u||u==="#"?"":u.startsWith("#")?u:"#"+u;function fv(u){return u!=null&&typeof u.status=="number"&&typeof u.statusText=="string"&&typeof u.internal=="boolean"&&"data"in u}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var Nm=["POST","PUT","PATCH","DELETE"];new Set(Nm);var dv=["GET",...Nm];new Set(dv);var Jl=E.createContext(null);Jl.displayName="DataRouter";var Du=E.createContext(null);Du.displayName="DataRouterState";E.createContext(!1);var Rm=E.createContext({isTransitioning:!1});Rm.displayName="ViewTransition";var hv=E.createContext(new Map);hv.displayName="Fetchers";var mv=E.createContext(null);mv.displayName="Await";var kt=E.createContext(null);kt.displayName="Navigation";var Fa=E.createContext(null);Fa.displayName="Location";var fn=E.createContext({outlet:null,matches:[],isDataRoute:!1});fn.displayName="Route";var Ar=E.createContext(null);Ar.displayName="RouteError";function pv(u,{relative:i}={}){Oe(Pa(),"useHref() may be used only in the context of a component.");let{basename:c,navigator:r}=E.useContext(kt),{hash:f,pathname:d,search:m}=Ia(u,{relative:i}),b=d;return c!=="/"&&(b=d==="/"?c:rn([c,d])),r.createHref({pathname:b,search:m,hash:f})}function Pa(){return E.useContext(Fa)!=null}function sl(){return Oe(Pa(),"useLocation() may be used only in the context of a component."),E.useContext(Fa).location}var Om="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function zm(u){E.useContext(kt).static||E.useLayoutEffect(u)}function yv(){let{isDataRoute:u}=E.useContext(fn);return u?Ov():vv()}function vv(){Oe(Pa(),"useNavigate() may be used only in the context of a component.");let u=E.useContext(Jl),{basename:i,navigator:c}=E.useContext(kt),{matches:r}=E.useContext(fn),{pathname:f}=sl(),d=JSON.stringify(Am(r)),m=E.useRef(!1);return zm(()=>{m.current=!0}),E.useCallback((y,v={})=>{if(jt(m.current,Om),!m.current)return;if(typeof y=="number"){c.go(y);return}let T=Cm(y,JSON.parse(d),f,v.relative==="path");u==null&&i!=="/"&&(T.pathname=T.pathname==="/"?i:rn([i,T.pathname])),(v.replace?c.replace:c.push)(T,v.state,v)},[i,c,d,f,u])}E.createContext(null);function Ia(u,{relative:i}={}){let{matches:c}=E.useContext(fn),{pathname:r}=sl(),f=JSON.stringify(Am(c));return E.useMemo(()=>Cm(u,JSON.parse(f),r,i==="path"),[u,f,r,i])}function gv(u,i){return Mm(u,i)}function Mm(u,i,c,r,f){Oe(Pa(),"useRoutes() may be used only in the context of a component.");let{navigator:d}=E.useContext(kt),{matches:m}=E.useContext(fn),b=m[m.length-1],y=b?b.params:{},v=b?b.pathname:"/",T=b?b.pathnameBase:"/",S=b&&b.route;{let Y=S&&S.path||"";Dm(v,!S||Y.endsWith("*")||Y.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${v}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let C=sl(),z;if(i){let Y=typeof i=="string"?Kl(i):i;Oe(T==="/"||Y.pathname?.startsWith(T),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${T}" but pathname "${Y.pathname}" was given in the \`location\` prop.`),z=Y}else z=C;let L=z.pathname||"/",X=L;if(T!=="/"){let Y=T.replace(/^\//,"").split("/");X="/"+L.replace(/^\//,"").split("/").slice(Y.length).join("/")}let q=_m(u,{pathname:X});jt(S||q!=null,`No routes matched location "${z.pathname}${z.search}${z.hash}" `),jt(q==null||q[q.length-1].route.element!==void 0||q[q.length-1].route.Component!==void 0||q[q.length-1].route.lazy!==void 0,`Matched leaf route at location "${z.pathname}${z.search}${z.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let H=Ev(q&&q.map(Y=>Object.assign({},Y,{params:Object.assign({},y,Y.params),pathname:rn([T,d.encodeLocation?d.encodeLocation(Y.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:Y.pathname]),pathnameBase:Y.pathnameBase==="/"?T:rn([T,d.encodeLocation?d.encodeLocation(Y.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:Y.pathnameBase])})),m,c,r,f);return i&&H?E.createElement(Fa.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...z},navigationType:"POP"}},H):H}function bv(){let u=Rv(),i=fv(u)?`${u.status} ${u.statusText}`:u instanceof Error?u.message:JSON.stringify(u),c=u instanceof Error?u.stack:null,r="rgba(200,200,200, 0.5)",f={padding:"0.5rem",backgroundColor:r},d={padding:"2px 4px",backgroundColor:r},m=null;return console.error("Error handled by React Router default ErrorBoundary:",u),m=E.createElement(E.Fragment,null,E.createElement("p",null,"💿 Hey developer 👋"),E.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",E.createElement("code",{style:d},"ErrorBoundary")," or"," ",E.createElement("code",{style:d},"errorElement")," prop on your route.")),E.createElement(E.Fragment,null,E.createElement("h2",null,"Unexpected Application Error!"),E.createElement("h3",{style:{fontStyle:"italic"}},i),c?E.createElement("pre",{style:f},c):null,m)}var Sv=E.createElement(bv,null),xv=class extends E.Component{constructor(u){super(u),this.state={location:u.location,revalidation:u.revalidation,error:u.error}}static getDerivedStateFromError(u){return{error:u}}static getDerivedStateFromProps(u,i){return i.location!==u.location||i.revalidation!=="idle"&&u.revalidation==="idle"?{error:u.error,location:u.location,revalidation:u.revalidation}:{error:u.error!==void 0?u.error:i.error,location:i.location,revalidation:u.revalidation||i.revalidation}}componentDidCatch(u,i){this.props.onError?this.props.onError(u,i):console.error("React Router caught the following error during render",u)}render(){return this.state.error!==void 0?E.createElement(fn.Provider,{value:this.props.routeContext},E.createElement(Ar.Provider,{value:this.state.error,children:this.props.component})):this.props.children}};function _v({routeContext:u,match:i,children:c}){let r=E.useContext(Jl);return r&&r.static&&r.staticContext&&(i.route.errorElement||i.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=i.route.id),E.createElement(fn.Provider,{value:u},c)}function Ev(u,i=[],c=null,r=null,f=null){if(u==null){if(!c)return null;if(c.errors)u=c.matches;else if(i.length===0&&!c.initialized&&c.matches.length>0)u=c.matches;else return null}let d=u,m=c?.errors;if(m!=null){let T=d.findIndex(S=>S.route.id&&m?.[S.route.id]!==void 0);Oe(T>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(m).join(",")}`),d=d.slice(0,Math.min(d.length,T+1))}let b=!1,y=-1;if(c)for(let T=0;T=0?d=d.slice(0,y+1):d=[d[0]];break}}}let v=c&&r?(T,S)=>{r(T,{location:c.location,params:c.matches?.[0]?.params??{},errorInfo:S})}:void 0;return d.reduceRight((T,S,C)=>{let z,L=!1,X=null,q=null;c&&(z=m&&S.route.id?m[S.route.id]:void 0,X=S.route.errorElement||Sv,b&&(y<0&&C===0?(Dm("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),L=!0,q=null):y===C&&(L=!0,q=S.route.hydrateFallbackElement||null)));let H=i.concat(d.slice(0,C+1)),Y=()=>{let k;return z?k=X:L?k=q:S.route.Component?k=E.createElement(S.route.Component,null):S.route.element?k=S.route.element:k=T,E.createElement(_v,{match:S,routeContext:{outlet:T,matches:H,isDataRoute:c!=null},children:k})};return c&&(S.route.ErrorBoundary||S.route.errorElement||C===0)?E.createElement(xv,{location:c.location,revalidation:c.revalidation,component:X,error:z,children:Y(),routeContext:{outlet:null,matches:H,isDataRoute:!0},onError:v}):Y()},null)}function Cr(u){return`${u} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Tv(u){let i=E.useContext(Jl);return Oe(i,Cr(u)),i}function Av(u){let i=E.useContext(Du);return Oe(i,Cr(u)),i}function Cv(u){let i=E.useContext(fn);return Oe(i,Cr(u)),i}function Nr(u){let i=Cv(u),c=i.matches[i.matches.length-1];return Oe(c.route.id,`${u} can only be used on routes that contain a unique "id"`),c.route.id}function Nv(){return Nr("useRouteId")}function Rv(){let u=E.useContext(Ar),i=Av("useRouteError"),c=Nr("useRouteError");return u!==void 0?u:i.errors?.[c]}function Ov(){let{router:u}=Tv("useNavigate"),i=Nr("useNavigate"),c=E.useRef(!1);return zm(()=>{c.current=!0}),E.useCallback(async(f,d={})=>{jt(c.current,Om),c.current&&(typeof f=="number"?u.navigate(f):await u.navigate(f,{fromRouteId:i,...d}))},[u,i])}var um={};function Dm(u,i,c){!i&&!um[u]&&(um[u]=!0,jt(!1,c))}E.memo(zv);function zv({routes:u,future:i,state:c,unstable_onError:r}){return Mm(u,void 0,c,r,i)}function vr(u){Oe(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function Mv({basename:u="/",children:i=null,location:c,navigationType:r="POP",navigator:f,static:d=!1}){Oe(!Pa(),"You cannot render a inside another . You should never have more than one in your app.");let m=u.replace(/^\/*/,"/"),b=E.useMemo(()=>({basename:m,navigator:f,static:d,future:{}}),[m,f,d]);typeof c=="string"&&(c=Kl(c));let{pathname:y="/",search:v="",hash:T="",state:S=null,key:C="default"}=c,z=E.useMemo(()=>{let L=on(y,m);return L==null?null:{location:{pathname:L,search:v,hash:T,state:S,key:C},navigationType:r}},[m,y,v,T,S,C,r]);return jt(z!=null,` is not able to match the URL "${y}${v}${T}" because it does not start with the basename, so the won't render anything.`),z==null?null:E.createElement(kt.Provider,{value:b},E.createElement(Fa.Provider,{children:i,value:z}))}function Dv({children:u,location:i}){return gv(gr(u),i)}function gr(u,i=[]){let c=[];return E.Children.forEach(u,(r,f)=>{if(!E.isValidElement(r))return;let d=[...i,f];if(r.type===E.Fragment){c.push.apply(c,gr(r.props.children,d));return}Oe(r.type===vr,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),Oe(!r.props.index||!r.props.children,"An index route cannot have child routes.");let m={id:r.props.id||d.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(m.children=gr(r.props.children,d)),c.push(m)}),c}var Tu="get",Au="application/x-www-form-urlencoded";function ju(u){return u!=null&&typeof u.tagName=="string"}function jv(u){return ju(u)&&u.tagName.toLowerCase()==="button"}function wv(u){return ju(u)&&u.tagName.toLowerCase()==="form"}function Uv(u){return ju(u)&&u.tagName.toLowerCase()==="input"}function Bv(u){return!!(u.metaKey||u.altKey||u.ctrlKey||u.shiftKey)}function Lv(u,i){return u.button===0&&(!i||i==="_self")&&!Bv(u)}var bu=null;function Hv(){if(bu===null)try{new FormData(document.createElement("form"),0),bu=!1}catch{bu=!0}return bu}var qv=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function or(u){return u!=null&&!qv.has(u)?(jt(!1,`"${u}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Au}"`),null):u}function Yv(u,i){let c,r,f,d,m;if(wv(u)){let b=u.getAttribute("action");r=b?on(b,i):null,c=u.getAttribute("method")||Tu,f=or(u.getAttribute("enctype"))||Au,d=new FormData(u)}else if(jv(u)||Uv(u)&&(u.type==="submit"||u.type==="image")){let b=u.form;if(b==null)throw new Error('Cannot submit a + + + + ))} + + + ); +} diff --git a/webui/src/components/AdvancedSettings.jsx b/webui/src/components/AdvancedSettings.jsx new file mode 100644 index 00000000..d08d3c35 --- /dev/null +++ b/webui/src/components/AdvancedSettings.jsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { useDriveControl } from '../context/DriveControlContext.jsx'; +import { useSocket } from '../context/SocketContext.jsx'; +import AuthPanel from './AuthPanel.jsx'; +import AdminPanel from './AdminPanel.jsx'; + +const manualTabs = [ + { key: 'start', label: 'Start OI' }, + { key: 'safe', label: 'Safe' }, + { key: 'full', label: 'Full' }, + { key: 'passive', label: 'Passive' }, + { key: 'dock', label: 'Dock' }, +]; + +export default function AdvancedSettings() { + const { roverId, sendOiCommand } = useDriveControl(); + const socket = useSocket(); + const canControl = Boolean(roverId); + + const sensorButtons = useMemo( + () => [ + { key: 'start', label: 'Enable stream', enable: true }, + { key: 'stop', label: 'Disable stream', enable: false }, + ], + [], + ); + + const handleSensorToggle = (enable) => { + if (!roverId) return; + socket.emit('command', { + roverId, + type: 'sensorStream', + data: { sensorStream: { enable } }, + }); + }; + + return ( +
+
+

Manual OI commands

+
+ {manualTabs.map((tab) => ( + + ))} +
+
+
+

Sensor stream

+
+ {sensorButtons.map((btn) => ( + + ))} +
+ {!canControl &&

Assign a rover to toggle streams.

} +
+ + +
+ ); +} diff --git a/webui/src/components/AlertFeed.jsx b/webui/src/components/AlertFeed.jsx new file mode 100644 index 00000000..952a2e8f --- /dev/null +++ b/webui/src/components/AlertFeed.jsx @@ -0,0 +1,54 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; + +const LIFETIME_MS = 5000; + +function buildKey(alert) { + if (alert.id) return alert.id; + if (alert.timestamp) return `${alert.timestamp}-${alert.message}`; + return `${alert.title || 'alert'}-${alert.message}`; +} + +export default function AlertFeed() { + const { alerts } = useSession(); + const [now, setNow] = useState(() => Date.now()); + const latest = useMemo(() => alerts.slice(-5).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]); + + useEffect(() => { + if (!latest.length) return undefined; + const interval = setInterval(() => setNow(Date.now()), 200); + return () => clearInterval(interval); + }, [latest.length]); + + const visible = latest + .map((item) => ({ + ...item, + age: now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0), + })) + .filter((item) => item.age <= LIFETIME_MS); + + if (!visible.length) return null; + + return ( +
+ {visible.map((toast) => ( + + ))} +
+ ); +} + +function AlertToast({ alert }) { + const colorClasses = + alert.color === 'error' + ? 'bg-red-500/30 text-red-100' + : alert.color === 'warn' + ? 'bg-amber-500/30 text-amber-100' + : 'bg-emerald-500/30 text-emerald-100'; + return ( +
+

{alert.title || 'Alert'}

+

{alert.message}

+
+ ); +} diff --git a/webui/src/components/AuthPanel.jsx b/webui/src/components/AuthPanel.jsx new file mode 100644 index 00000000..1f0e7727 --- /dev/null +++ b/webui/src/components/AuthPanel.jsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; + +export default function AuthPanel() { + const { login, setRole } = useSession(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleLogin(event) { + event.preventDefault(); + setLoading(true); + try { + await login(username, password); + setUsername(''); + setPassword(''); + } catch (err) { + alert(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+

Admin login

+ + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + +
+ + +
+
+ ); +} diff --git a/webui/src/components/DrivePanel.jsx b/webui/src/components/DrivePanel.jsx new file mode 100644 index 00000000..dcbc77a1 --- /dev/null +++ b/webui/src/components/DrivePanel.jsx @@ -0,0 +1,71 @@ +import { useDriveControl } from '../context/DriveControlContext.jsx'; +import { useTelemetryFrame } from '../context/TelemetryContext.jsx'; + +export default function DrivePanel() { + const { roverId, runStartDockFull, seekDock } = useDriveControl(); + const frame = useTelemetryFrame(roverId); + const sensors = frame?.sensors || {}; + const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full'; + const docked = Boolean(sensors.chargingSources?.homeBase); + const charging = Boolean( + sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging', + ); + + return ( +
+
+ Drive control + {roverId ? `Rover ${roverId}` : 'unassigned'} +
+
+ + +
+
+ ); +} + +function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) { + const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600'; + return ( + + ); +} diff --git a/webui/src/components/DriverVideoPanel.jsx b/webui/src/components/DriverVideoPanel.jsx new file mode 100644 index 00000000..c55c4d8d --- /dev/null +++ b/webui/src/components/DriverVideoPanel.jsx @@ -0,0 +1,35 @@ +import { useSession } from '../context/SessionContext.jsx'; +import { useTelemetryFrame } from '../context/TelemetryContext.jsx'; +import { useVideoRequests } from '../hooks/useVideoRequests.js'; +import VideoTile from './VideoTile.jsx'; + +export default function DriverVideoPanel() { + const { session } = useSession(); + const roverId = session?.assignment?.roverId; + const sources = useVideoRequests(roverId ? [roverId] : []); + const info = roverId ? sources[roverId] : null; + const frame = useTelemetryFrame(roverId); + const batteryRecord = + roverId && session?.roster + ? session.roster.find((item) => String(item.id) === String(roverId)) + : null; + const batteryConfig = batteryRecord?.battery ?? null; + + return ( +
+ {roverId ? ( +
+ +
+ ) : ( +

Assign a rover to view the video feed.

+ )} +
+ ); +} diff --git a/webui/src/components/HelpPanel.jsx b/webui/src/components/HelpPanel.jsx new file mode 100644 index 00000000..3aeb2a08 --- /dev/null +++ b/webui/src/components/HelpPanel.jsx @@ -0,0 +1,32 @@ +export default function HelpPanel({ layout }) { + const presets = { + desktop: [ + 'Use WASD + Shift to drive. Video must stay focused.', + 'Macros on the left prep the rover before moving.', + 'Switch tabs on the right for telemetry, room, and advanced tools.', + ], + 'mobile-portrait': [ + 'Keep joystick centered when not driving.', + 'Auxiliary buttons sit beside the joystick – use them carefully.', + 'Scroll to access drive/telemetry if screen space is limited.', + ], + 'mobile-landscape': [ + 'Joystick and video are side by side for game-style control.', + 'Rotate back to portrait if you need admin controls quickly.', + 'Tabs are desktop-only; mobile shows sections stacked.', + ], + }; + const tips = presets[layout] || presets.desktop; + return ( +
+

Help

+
    + {tips.map((tip) => ( +
  • + {tip} +
  • + ))} +
+
+ ); +} diff --git a/webui/src/components/LogPanel.jsx b/webui/src/components/LogPanel.jsx new file mode 100644 index 00000000..2012fbbc --- /dev/null +++ b/webui/src/components/LogPanel.jsx @@ -0,0 +1,30 @@ +import { useSession } from '../context/SessionContext.jsx'; + +export default function LogPanel() { + const { logs } = useSession(); + return ( +
+
+ Server logs + {logs.length} +
+
+ {logs.length === 0 ? ( +

No logs yet.

+ ) : ( + logs + .slice() + .reverse() + .map((entry) => ( +
+ {entry.timestamp}{' '} + [{entry.level}]{' '} + {entry.label && [{entry.label}]}{' '} + {entry.message} +
+ )) + )} +
+
+ ); +} diff --git a/webui/src/components/MobileControls.jsx b/webui/src/components/MobileControls.jsx new file mode 100644 index 00000000..20e4acfc --- /dev/null +++ b/webui/src/components/MobileControls.jsx @@ -0,0 +1,132 @@ +import { useCallback } from 'react'; +import { Joystick } from 'react-joystick-component'; +import { useDriveControl } from '../context/DriveControlContext.jsx'; + +function clampUnit(value = 0) { + if (typeof value !== 'number' || Number.isNaN(value)) return 0; + return Math.max(-1, Math.min(1, value)); +} + +function MobileJoystick() { + const { roverId, driveWithVector, stopMotors } = useDriveControl(); + const disabled = !roverId; + + const handleMove = useCallback( + (event = {}) => { + if (disabled) return; + const x = clampUnit(event.x ?? 0); + const y = clampUnit(event.y ?? 0); + driveWithVector({ x, y }); + }, + [disabled, driveWithVector], + ); + + const handleStop = useCallback(() => { + if (disabled) return; + driveWithVector({ x: 0, y: 0 }); + }, [disabled, driveWithVector]); + + return ( +
+
Joystick
+
+ +
+

+ Drag to drive. Release to stop sending drive commands. +

+ +
+ ); +} + +function AuxMotorControls() { + const { roverId, setAuxMotors } = useDriveControl(); + const disabled = !roverId; + + const runAllForward = () => { + if (disabled) return; + setAuxMotors({ main: 127, side: 127, vacuum: 127 }); + }; + + const stopAll = () => { + if (disabled) return; + setAuxMotors({ main: 0, side: 0, vacuum: 0 }); + }; + + const auxButtons = [ + { label: 'Main +', values: { main: 127 } }, + { label: 'Main -', values: { main: -127 } }, + { label: 'Side +', values: { side: 127 } }, + { label: 'Side -', values: { side: -127 } }, + { label: 'Vacuum Max', values: { vacuum: 127 } }, + { label: 'Vacuum Off', values: { vacuum: 0 } }, + ]; + + return ( +
+
Aux motors
+
+ + +
+
+ {auxButtons.map((btn) => ( + + ))} +
+
+ ); +} + +export default function MobileControlsStack() { + return ( +
+
+ +
+
+ +
+
+ ); +} + +export { MobileJoystick, AuxMotorControls }; diff --git a/webui/src/components/ModeGateOverlay.jsx b/webui/src/components/ModeGateOverlay.jsx new file mode 100644 index 00000000..3c5f2c62 --- /dev/null +++ b/webui/src/components/ModeGateOverlay.jsx @@ -0,0 +1,52 @@ +import AuthPanel from './AuthPanel.jsx'; +import { useSession } from '../context/SessionContext.jsx'; + +const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']); +const RESTRICTED_MODES = new Set(['admin', 'lockdown']); + +function getModeDetails(mode = 'admin') { + if (mode === 'lockdown') { + return { + title: 'Lockdown in effect', + description: + 'Only the lockdown admin can remain connected right now. If you have credentials, log in below. Otherwise please stand by until the server re-opens.', + }; + } + return { + title: 'Admin mode active', + description: + 'The server is temporarily restricted to administrators. Log in below if you are authorized, or wait for admin mode to end.', + }; +} + +export default function ModeGateOverlay() { + const { session } = useSession(); + const mode = session?.mode; + const role = session?.role; + const restricted = RESTRICTED_MODES.has(mode); + const privileged = PRIVILEGED_ROLES.has(role); + + if (!restricted || privileged) { + return null; + } + + const details = getModeDetails(mode); + + return ( +
+
+
+

{details.title}

+

{details.description}

+
+
+ +
+

+ Your controls are paused until access is granted. You will automatically regain the interface once the mode + changes or after a successful login. +

+
+
+ ); +} diff --git a/webui/src/components/RightPaneTabs.jsx b/webui/src/components/RightPaneTabs.jsx new file mode 100644 index 00000000..95ff6f27 --- /dev/null +++ b/webui/src/components/RightPaneTabs.jsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import TelemetryPanel from './TelemetryPanel.jsx'; +import DrivePanel from './DrivePanel.jsx'; +import RoomCameraPanel from './RoomCameraPanel.jsx'; +import AdvancedSettings from './AdvancedSettings.jsx'; +import HelpPanel from './HelpPanel.jsx'; + +function TabButton({ active, onClick, children }) { + return ( + + ); +} + +function RoomControlsPlaceholder() { + return ( +
+

Room controls

+

Coming soon: lighting, docks, and environmental toggles.

+
+ ); +} + +export default function RightPaneTabs({ layout }) { + const [active, setActive] = useState('telemetry'); + + return ( +
+
+ setActive('telemetry')}> + Rover Controls + + setActive('room')}> + Room Controls + + setActive('advanced')}> + Advanced + + setActive('help')}> + Help + +
+
+ {active === 'telemetry' && ( +
+ + +
+ )} + {active === 'room' && ( +
+ + +
+ )} + {active === 'advanced' && } + {active === 'help' && } +
+
+ ); +} diff --git a/webui/src/components/RoomCameraPanel.jsx b/webui/src/components/RoomCameraPanel.jsx new file mode 100644 index 00000000..39b3e7fe --- /dev/null +++ b/webui/src/components/RoomCameraPanel.jsx @@ -0,0 +1,8 @@ +export default function RoomCameraPanel() { + return ( +
+

Room camera

+

Feed placeholder. Wire upcoming room cam here.

+
+ ); +} diff --git a/webui/src/components/SessionSnapshot.jsx b/webui/src/components/SessionSnapshot.jsx new file mode 100644 index 00000000..02d1adeb --- /dev/null +++ b/webui/src/components/SessionSnapshot.jsx @@ -0,0 +1,13 @@ +import { useMemo } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; + +export default function SessionSnapshot() { + const { session } = useSession(); + const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]); + return ( +
+

Session snapshot

+
{payload}
+
+ ); +} diff --git a/webui/src/components/TelemetryPanel.jsx b/webui/src/components/TelemetryPanel.jsx new file mode 100644 index 00000000..e4b1fe2d --- /dev/null +++ b/webui/src/components/TelemetryPanel.jsx @@ -0,0 +1,145 @@ +import { useMemo, useState } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; +import { useTelemetryFrame } from '../context/TelemetryContext.jsx'; + +function formatMetric(value, fallback = '--') { + if (value == null || value === '') return fallback; + return value; +} + +export default function TelemetryPanel() { + const { connected, session, requestControl } = useSession(); + const roverId = session?.assignment?.roverId; + const frame = useTelemetryFrame(roverId); + const sensors = frame?.sensors || {}; + const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null; + const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null; + const charge = sensors.batteryChargeMah; + const capacity = sensors.batteryCapacityMah; + const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null; + // const rawSnippet = frame?.raw ? (frame.raw.length > 80 ? `${frame.raw.slice(0, 80)}…` : frame.raw) : null; + const rawSnippet = frame?.raw ? frame.raw : null; + const roster = session?.roster ?? []; + const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null; + const driverLabel = !roverId + ? 'n/a' + : activeDriverId + ? activeDriverId === session?.socketId + ? 'You' + : activeDriverId.slice(0, 6) + : 'Available'; + const isAdmin = useMemo( + () => session?.role === 'admin' || session?.role === 'lockdown' || session?.role === 'lockdown-admin', + [session?.role], + ); + const [pending, setPending] = useState({}); + + async function handleRequest(roverId) { + if (!roverId) return; + setPending((prev) => ({ ...prev, [roverId]: true })); + try { + await requestControl(roverId); + } catch (err) { + alert(err.message); + } finally { + setPending((prev) => ({ ...prev, [roverId]: false })); + } + } + + return ( +
+
+ {connected ? 'online' : 'offline'} + · role {session?.role || 'unknown'} + · mode {session?.mode || '--'} + {updated && · sensors {updated}} + · driver {driverLabel} +
+ {!roverId ? ( +

Assign a rover to view sensors.

+ ) : !frame ? ( +

Waiting for sensor frames…

+ ) : ( + <> +
+ + + + + + + +
+
+ + + + +
+ + + )} + + + + {rawSnippet && ( + // console.log('rawSnippet:', rawSnippet), +
+          {rawSnippet}
+        
+ )} +
+

Rovers

+ {roster.length === 0 ? ( + // if there ARE NO rovers +

No roster data.

+ ) : ( + // if there ARE rovers +
    + {roster.map((rover) => ( +
  • +
    +

    {rover.name}

    +

    + {rover.locked ? 'locked' : 'free'} · {rover.lastSeen ? 'seen' : 'unknown'} +

    +
    + {isAdmin && ( + + )} +
  • + ))} +
+ )} +
+
+ ); +} + +function CliffBar({ value }) { + // Render a small vertical bar; value is clamped to 0-100 for display + // const pct = value == null ? 0 : Math.max(0, Math.min(100, value)); + // goes from 0 to 4095. calculate percentage for height + const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100)); + const height = `${pct}%`; + return ( +
+
+
+ ); +} + +function Metric({ label, value }) { + return ( +
+ {label}: {value} +
+ ); +} diff --git a/webui/src/components/VideoTile.jsx b/webui/src/components/VideoTile.jsx new file mode 100644 index 00000000..a32cb304 --- /dev/null +++ b/webui/src/components/VideoTile.jsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { WhepPlayer } from '../lib/whepPlayer.js'; + +const RESTART_DELAY_MS = 2000; +const UNMUTE_RETRY_MS = 3000; + +export default function VideoTile({ sessionInfo, label, forceMute = false, telemetryFrame, batteryConfig }) { + const videoRef = useRef(null); + const restartTimer = useRef(null); + const unmuteTimer = useRef(null); + const [status, setStatus] = useState('idle'); + const [detail, setDetail] = useState(null); + const [restartToken, setRestartToken] = useState(0); + const [muted, setMuted] = useState(true); + const sensors = telemetryFrame?.sensors; + const batteryCharge = sensors?.batteryChargeMah ?? null; + // console.log('[BatteryBarDebug]', { + // frameSensors: sensors, + // batteryCharge, + // batteryCapacity, + // config: batteryConfig, + // }); + const wheelOvercurrents = sensors?.wheelOvercurrents || null; + const overcurrentActive = Boolean( + wheelOvercurrents && Object.values(wheelOvercurrents).some((value) => Boolean(value)), + ); + + const scheduleRestart = useCallback(() => { + clearTimeout(restartTimer.current); + restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS); + }, []); + + const attemptUnmute = useCallback( + (delay = 0) => { + if (forceMute) return; + clearTimeout(unmuteTimer.current); + + const scheduleRetry = () => { + clearTimeout(unmuteTimer.current); + unmuteTimer.current = setTimeout(() => { + if (!forceMute) { + tryPlay(); + } + }, UNMUTE_RETRY_MS); + }; + + const tryPlay = async () => { + const video = videoRef.current; + if (!video) return; + try { + video.muted = false; + await video.play(); + setMuted(false); + } catch { + video.muted = true; + setMuted(true); + scheduleRetry(); + } + }; + + unmuteTimer.current = setTimeout(tryPlay, delay); + }, + [forceMute], + ); + + useEffect( + () => () => { + clearTimeout(restartTimer.current); + clearTimeout(unmuteTimer.current); + }, + [], + ); + + useEffect(() => { + if (status === 'playing') { + attemptUnmute(0); + } + }, [status, attemptUnmute]); + + useEffect(() => { + if (!sessionInfo?.url || !videoRef.current) { + return undefined; + } + let active = true; + let player; + const resetMuteId = setTimeout(() => setMuted(true), 0); + const handleStatus = (nextStatus, info) => { + if (!active) return; + setStatus(nextStatus); + setDetail(info || null); + if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) { + scheduleRestart(); + } + }; + + player = new WhepPlayer({ + url: sessionInfo.url, + token: sessionInfo.token, + video: videoRef.current, + onStatus: handleStatus, + }); + + player.start().catch((err) => { + if (!active) return; + setStatus('error'); + setDetail(err.message); + scheduleRestart(); + }); + + return () => { + active = false; + clearTimeout(resetMuteId); + player?.stop(); + }; + }, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]); + + useEffect(() => { + if (status === 'stopped' && sessionInfo?.url) { + scheduleRestart(); + } + }, [status, sessionInfo?.url, scheduleRestart]); + + const renderedStatus = !sessionInfo?.url + ? 'waiting' + : status === 'error' + ? `Error: ${detail || 'unknown'}` + : detail + ? `${status} (${detail})` + : status; + + return ( +
+
+
+ +
+ ); +} + +function BatteryBar({ charge, config, label, status }) { + const full = config?.Full; + const warn = config?.Warn; + const urgent = config?.Urgent ?? null; + if (charge == null || full == null || warn == null) { + return ( +
+
+ {label} + {status} +
+

Battery telemetry unavailable

+
+ ); + } + + const span = full - warn; + if (span <= 0) return null; + const normalized = (charge - warn) / span; + const percent = Math.min(1, Math.max(0, normalized)); + const percentDisplay = Math.round(percent * 100); + const percentText = `${percentDisplay}%`; + const depleted = normalized <= 0; + const warnTriggered = urgent != null && charge <= urgent; + const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500'; + return ( +
+
+ {label} + {status} +
+
+
+ + {percentText} + +
+
+ ); +} + +function HudOverlay({ frame }) { + const sensors = frame?.sensors; + const bumps = sensors?.bumpsAndWheelDrops || {}; + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 150); + return () => clearInterval(interval); + }, []); + + const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false; + + const bumperBadges = [ + { label: 'B-L', active: bumps.bumpLeft }, + { label: 'B-R', active: bumps.bumpRight }, + ]; + const wheelBadges = [ + { label: 'Drop L', active: bumps.wheelDropLeft }, + { label: 'Drop R', active: bumps.wheelDropRight }, + ]; + + return ( +
+
+
+ + sensor +
+
+ {bumperBadges.map((badge) => ( + + ))} +
+
+
+ {wheelBadges.map((badge) => ( + + ))} +
+
+ ); +} + +function HudBadge({ label, active }) { + return ( + + {label} + + ); +} + +function OvercurrentOverlay({ active }) { + if (!active) return null; + return ( +
+
Overcurrent
+
+ ); +} diff --git a/webui/src/context/DriveControlContext.jsx b/webui/src/context/DriveControlContext.jsx new file mode 100644 index 00000000..121d63ba --- /dev/null +++ b/webui/src/context/DriveControlContext.jsx @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; +import { useDriveControls as useDriveControlsHook } from '../hooks/useDriveControls.js'; + +/* eslint-disable react-refresh/only-export-components */ + +const DriveControlContext = createContext(null); + +export function DriveControlProvider({ children }) { + const controls = useDriveControlsHook(); + return {children}; +} + +export function useDriveControl() { + const context = useContext(DriveControlContext); + if (!context) { + throw new Error('useDriveControl must be used within DriveControlProvider'); + } + return context; +} diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx new file mode 100644 index 00000000..8027f6eb --- /dev/null +++ b/webui/src/context/SessionContext.jsx @@ -0,0 +1,112 @@ +/* eslint-disable react-refresh/only-export-components */ + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useSocket } from './SocketContext.jsx'; + +const SessionContext = createContext({ + connected: false, + session: null, + logs: [], + login: async () => {}, + setRole: async () => {}, + requestControl: async () => {}, + releaseControl: async () => {}, + subscribeAll: async () => {}, +}); + +function useAckEmitter(socket) { + return useCallback( + (event, payload = {}) => + new Promise((resolve, reject) => { + socket.emit(event, payload, (resp = {}) => { + if (resp.error) { + reject(new Error(resp.error)); + } else { + resolve(resp); + } + }); + }), + [socket], + ); +} + +export function SessionProvider({ children }) { + const socket = useSocket(); + const emitWithAck = useAckEmitter(socket); + const [session, setSession] = useState(null); + const [logs, setLogs] = useState([]); + const [alerts, setAlerts] = useState([]); + const [connected, setConnected] = useState(socket.connected); + + useEffect(() => { + const onConnect = () => setConnected(true); + const onDisconnect = () => setConnected(false); + socket.on('connect', onConnect); + socket.on('disconnect', onDisconnect); + return () => { + socket.off('connect', onConnect); + socket.off('disconnect', onDisconnect); + }; + }, [socket]); + + useEffect(() => { + function handleSession(payload) { + setSession(payload); + } + function handleLogInit(entries = []) { + setLogs(entries); + } + function handleLogEntry(entry) { + setLogs((prev) => [...prev.slice(-199), entry]); + } + socket.on('session:sync', handleSession); + socket.on('log:init', handleLogInit); + socket.on('log:entry', handleLogEntry); + socket.on('alert:new', (payload = {}) => { + setAlerts((prev) => [ + ...prev.slice(-49), + { + ...payload, + receivedAt: Date.now(), + }, + ]); + }); + return () => { + socket.off('session:sync', handleSession); + socket.off('log:init', handleLogInit); + socket.off('log:entry', handleLogEntry); + socket.off('alert:new'); + }; + }, [socket]); + + const actions = useMemo( + () => ({ + login: (username, password) => emitWithAck('auth:login', { username, password }), + setRole: (role) => emitWithAck('session:setRole', { role }), + requestControl: (roverId, options = {}) => + emitWithAck('session:requestControl', { roverId, ...options }), + releaseControl: (roverId) => emitWithAck('session:releaseControl', { roverId }), + subscribeAll: () => emitWithAck('session:subscribeAll'), + lockRover: (roverId, locked) => emitWithAck('session:lockRover', { roverId, locked }), + setMode: (mode) => emitWithAck('setMode', { mode }), + }), + [emitWithAck], + ); + + const value = useMemo( + () => ({ + connected, + session, + logs, + alerts, + ...actions, + }), + [actions, alerts, connected, logs, session], + ); + + return {children}; +} + +export function useSession() { + return useContext(SessionContext); +} diff --git a/webui/src/context/SocketContext.jsx b/webui/src/context/SocketContext.jsx new file mode 100644 index 00000000..fd96a846 --- /dev/null +++ b/webui/src/context/SocketContext.jsx @@ -0,0 +1,11 @@ +/* eslint-disable react-refresh/only-export-components */ + +// src/context/SocketContext.jsx +import { createContext, useContext } from "react"; +import { socket } from "../lib/socket"; + +const SocketContext = createContext(socket); +export const SocketProvider = ({ children }) => ( + {children} +); +export const useSocket = () => useContext(SocketContext); diff --git a/webui/src/context/TelemetryContext.jsx b/webui/src/context/TelemetryContext.jsx new file mode 100644 index 00000000..0a37f312 --- /dev/null +++ b/webui/src/context/TelemetryContext.jsx @@ -0,0 +1,44 @@ +/* eslint-disable react-refresh/only-export-components */ + +import { createContext, useContext, useMemo, useState, useEffect } from 'react'; +import { useSocket } from './SocketContext.jsx'; + +const TelemetryContext = createContext({ frames: {} }); + +export function TelemetryProvider({ children }) { + const socket = useSocket(); + const [frames, setFrames] = useState({}); + + useEffect(() => { + function handleSensorFrame({ roverId, sensors = {}, frame = {} }) { + if (!roverId) return; + setFrames((prev) => ({ + ...prev, + [roverId]: { + roverId, + sensors, + raw: frame?.data || null, + receivedAt: Date.now(), + }, + })); + } + + socket.on('sensorFrame', handleSensorFrame); + return () => { + socket.off('sensorFrame', handleSensorFrame); + }; + }, [socket]); + + const value = useMemo(() => ({ frames }), [frames]); + return {children}; +} + +export function useTelemetryFrames() { + return useContext(TelemetryContext).frames; +} + +export function useTelemetryFrame(roverId) { + const frames = useTelemetryFrames(); + if (!roverId) return null; + return frames[roverId] ?? null; +} diff --git a/webui/src/hooks/useDriveControls.js b/webui/src/hooks/useDriveControls.js new file mode 100644 index 00000000..79a578ef --- /dev/null +++ b/webui/src/hooks/useDriveControls.js @@ -0,0 +1,253 @@ +/* global Buffer */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSocket } from '../context/SocketContext.jsx'; +import { useSession } from '../context/SessionContext.jsx'; + +const OI_COMMANDS = { + start: [128], + safe: [131], + full: [132], + passive: [128], + dock: [143], +}; + +const AUX_LIMITS = { + main: [-127, 127], + side: [-127, 127], + vacuum: [0, 127], +}; + +const COMMAND_DELAY_MS = 200; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function computeSpeeds(keys) { + const forward = keys.has('w'); + const backward = keys.has('s'); + const left = keys.has('a'); + const right = keys.has('d'); + const fast = keys.has('shift'); + const base = fast ? 300 : 150; + let leftSpeed = 0; + let rightSpeed = 0; + + if (forward && !backward) { + leftSpeed += base; + rightSpeed += base; + } else if (backward && !forward) { + leftSpeed -= base; + rightSpeed -= base; + } + + if (left && !right) { + leftSpeed -= base; + rightSpeed += base; + } else if (right && !left) { + leftSpeed += base; + rightSpeed -= base; + } + + if (!forward && !backward && (left || right)) { + leftSpeed = left ? -base : base; + rightSpeed = left ? base : -base; + } + + return { + left: clamp(leftSpeed, -500, 500), + right: clamp(rightSpeed, -500, 500), + }; +} + +function clampUnit(value) { + if (typeof value !== 'number' || Number.isNaN(value)) return 0; + return Math.max(-1, Math.min(1, value)); +} + +function shouldIgnoreEvent(event) { + const target = event.target; + if (!target) return false; + const tag = target.tagName; + return ( + tag === 'INPUT' || + tag === 'TEXTAREA' || + target.isContentEditable || + (tag === 'SELECT' && ['w', 'a', 's', 'd', 'Shift'].includes(event.key)) + ); +} + +function bytesToBase64(bytes) { + const binary = String.fromCharCode(...bytes); + if (typeof btoa === 'function') return btoa(binary); + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64'); + } + throw new Error('No base64 encoder available'); +} + +export function useDriveControls() { + const socket = useSocket(); + const { session } = useSession(); + const roverId = session?.assignment?.roverId; + const keysRef = useRef(new Set()); + const lastSpeedsRef = useRef({ left: 0, right: 0 }); + const [currentSpeeds, setCurrentSpeeds] = useState(() => ({ left: 0, right: 0 })); + + const emitCommand = useCallback( + (payload, cb) => { + if (!roverId) return; + socket.emit('command', { roverId, ...payload }, cb); + }, + [socket, roverId], + ); + + const enableSensorStream = useCallback(() => { + if (!roverId) return; + emitCommand({ + type: 'sensorStream', + data: { sensorStream: { enable: true } }, + }); + }, [emitCommand, roverId]); + + const sendDriveUpdate = useCallback(() => { + if (!roverId) return; + const speeds = computeSpeeds(keysRef.current); + const last = lastSpeedsRef.current; + if (speeds.left === last.left && speeds.right === last.right) return; + lastSpeedsRef.current = speeds; + setCurrentSpeeds(speeds); + emitCommand({ + type: 'drive', + data: { driveDirect: speeds }, + }); + }, [emitCommand, roverId]); + + const driveWithVector = useCallback( + ({ x = 0, y = 0, boost = false } = {}) => { + if (!roverId) return; + const base = boost ? 400 : 250; + const forward = clampUnit(y) * base; + const turn = clampUnit(x) * base; + const speeds = { + left: clamp(Math.round(forward + turn), -500, 500), + right: clamp(Math.round(forward - turn), -500, 500), + }; + lastSpeedsRef.current = speeds; + setCurrentSpeeds(speeds); + emitCommand({ + type: 'drive', + data: { driveDirect: speeds }, + }); + }, + [emitCommand, roverId], + ); + + const sendMotorPwm = useCallback( + ({ main = 0, side = 0, vacuum = 0 } = {}) => { + if (!roverId) return; + const payload = { + main: clamp(main, AUX_LIMITS.main[0], AUX_LIMITS.main[1]), + side: clamp(side, AUX_LIMITS.side[0], AUX_LIMITS.side[1]), + vacuum: clamp(vacuum, AUX_LIMITS.vacuum[0], AUX_LIMITS.vacuum[1]), + }; + emitCommand({ + type: 'motors', + data: { motorPwm: payload }, + }); + }, + [emitCommand, roverId], + ); + + const stopMotors = useCallback(() => { + if (!roverId) return; + keysRef.current.clear(); + lastSpeedsRef.current = { left: 0, right: 0 }; + setCurrentSpeeds(lastSpeedsRef.current); + sendMotorPwm({ main: 0, side: 0, vacuum: 0 }); + }, [roverId, sendMotorPwm]); + + const sendOiCommand = useCallback( + (key) => { + if (!roverId) return; + const bytes = OI_COMMANDS[key]; + if (!bytes) return; + emitCommand({ + type: 'raw', + data: { raw: bytesToBase64(bytes) }, + }); + enableSensorStream(); + }, + [emitCommand, enableSensorStream, roverId], + ); + + const runStartDockFull = useCallback(async () => { + if (!roverId) return; + for (const key of ['start', 'dock', 'full']) { + sendOiCommand(key); + // brief pause so the commands aren't collapsed by the rover + await sleep(COMMAND_DELAY_MS); + } + }, [roverId, sendOiCommand]); + + const seekDock = useCallback(() => { + sendOiCommand('dock'); + }, [sendOiCommand]); + + const setAuxMotors = useCallback( + (values) => { + if (!roverId) return; + sendMotorPwm(values); + }, + [roverId, sendMotorPwm], + ); + + useEffect(() => { + if (!roverId) { + keysRef.current.clear(); + lastSpeedsRef.current = { left: 0, right: 0 }; + setCurrentSpeeds(lastSpeedsRef.current); + return undefined; + } + + function onKeyDown(event) { + if (shouldIgnoreEvent(event)) return; + const key = event.key.toLowerCase(); + if (!['w', 'a', 's', 'd', 'shift'].includes(key)) return; + if (keysRef.current.has(key)) return; + keysRef.current.add(key); + sendDriveUpdate(); + } + + function onKeyUp(event) { + const key = event.key.toLowerCase(); + if (!keysRef.current.has(key)) return; + keysRef.current.delete(key); + sendDriveUpdate(); + } + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + + enableSensorStream(); + + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, [roverId, sendDriveUpdate, enableSensorStream]); + + return { + roverId, + speeds: currentSpeeds, + stopMotors, + sendOiCommand, + runStartDockFull, + seekDock, + setAuxMotors, + driveWithVector, + }; +} diff --git a/webui/src/hooks/useSpectatorMode.js b/webui/src/hooks/useSpectatorMode.js new file mode 100644 index 00000000..5e892b0c --- /dev/null +++ b/webui/src/hooks/useSpectatorMode.js @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; + +export function useSpectatorMode() { + const { session, setRole, subscribeAll } = useSession(); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + async function ensureSpectator() { + try { + if (session?.role !== 'spectator') { + await setRole('spectator'); + } + await subscribeAll(); + if (!cancelled) { + setReady(true); + } + } catch (err) { + console.error('Failed to enter spectator mode', err); + } + } + ensureSpectator(); + return () => { + cancelled = true; + }; + }, [session?.role, setRole, subscribeAll]); + + return ready; +} diff --git a/webui/src/hooks/useVideoRequests.js b/webui/src/hooks/useVideoRequests.js new file mode 100644 index 00000000..1c4d9cda --- /dev/null +++ b/webui/src/hooks/useVideoRequests.js @@ -0,0 +1,43 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSocket } from '../context/SocketContext.jsx'; + +export function useVideoRequests(roverIds = []) { + const socket = useSocket(); + const [sources, setSources] = useState({}); + const normalizedKey = Array.isArray(roverIds) ? roverIds.filter(Boolean).join('|') : ''; + + useEffect(() => { + if (!normalizedKey) { + return undefined; + } + const ids = normalizedKey.split('|'); + let cancelled = false; + ids.forEach((roverId) => { + socket.emit('video:request', { roverId }, (resp = {}) => { + if (cancelled) return; + setSources((prev) => ({ + ...prev, + [roverId]: resp, + })); + }); + }); + + return () => { + cancelled = true; + }; + }, [socket, normalizedKey]); + + const filtered = useMemo(() => { + if (!normalizedKey) return {}; + const ids = normalizedKey.split('|'); + const next = {}; + ids.forEach((id) => { + if (sources[id]) { + next[id] = sources[id]; + } + }); + return next; + }, [normalizedKey, sources]); + + return filtered; +} diff --git a/webui/src/index.css b/webui/src/index.css new file mode 100644 index 00000000..39ef9bfd --- /dev/null +++ b/webui/src/index.css @@ -0,0 +1,16 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', + sans-serif; + color: #0f172a; + background-color: #f8fafc; +} + +body { + margin: 0; + min-height: 100vh; + background-color: #0f172a; +} diff --git a/webui/src/lib/socket.js b/webui/src/lib/socket.js new file mode 100644 index 00000000..e12e63d1 --- /dev/null +++ b/webui/src/lib/socket.js @@ -0,0 +1,10 @@ +import { io } from 'socket.io-client'; + +const configured = import.meta.env.VITE_ROVERD_URL?.trim(); +const resolvedUrl = configured && configured.length > 0 ? configured : window.location.origin; +console.info('[socket] connecting to', resolvedUrl); +export const socket = io(resolvedUrl, { + transports: ['polling', 'websocket'], + timeout: 15000, +}); +socket.on('connect_error', (err) => console.error('connect_error', err.code, err.message, err.data)); diff --git a/webui/src/lib/whepPlayer.js b/webui/src/lib/whepPlayer.js new file mode 100644 index 00000000..69121798 --- /dev/null +++ b/webui/src/lib/whepPlayer.js @@ -0,0 +1,139 @@ +/* global Buffer */ + +const RTC_CONFIG = { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'turn:your.turn.server:3478', username: 'user', credential: 'pass' }, + ], + bundlePolicy: 'max-bundle', + rtcpMuxPolicy: 'require', +}; + + +function encodeBase64(value) { + if (typeof btoa === 'function') { + return btoa(value); + } + if (typeof Buffer !== 'undefined') { + return Buffer.from(value).toString('base64'); + } + throw new Error('No base64 encoder available'); +} + +function buildAuthHeader(token) { + if (!token) return {}; + const credential = `${token}:${token}`; + const encoded = encodeBase64(credential); + return { Authorization: `Basic ${encoded}` }; +} + +export class WhepPlayer { + constructor({ url, token, video, onStatus }) { + this.url = url; + this.token = token; + this.video = video; + this.pc = null; + this.abortController = null; + this.onStatus = onStatus; + } + + notify(status, detail) { + if (typeof this.onStatus === 'function') { + this.onStatus(status, detail); + } + } + + configureVideoElement() { + if (!this.video) return; + this.video.playsInline = true; + this.video.autoplay = true; + this.video.disableRemotePlayback = true; + if ('latencyHint' in HTMLMediaElement.prototype) { + this.video.latencyHint = 'interactive'; + } + } + + async start() { + if (!this.url || !this.video) { + throw new Error('Video target missing'); + } + this.stop(); + this.notify('connecting'); + this.configureVideoElement(); + this.abortController = new AbortController(); + const pc = new RTCPeerConnection(RTC_CONFIG); + this.pc = pc; + const stream = new MediaStream(); + pc.ontrack = (event) => { + event.streams[0]?.getTracks().forEach((mediaTrack) => stream.addTrack(mediaTrack)); + this.video.srcObject = stream; + if (event.track?.kind === 'video' && 'playoutDelayHint' in event.receiver) { + event.receiver.playoutDelayHint = 0; + } + }; + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + + pc.onconnectionstatechange = () => { + const state = pc.connectionState; + this.notify(state); + }; + pc.oniceconnectionstatechange = () => { + const state = pc.iceConnectionState; + if (state === 'failed' || state === 'disconnected') { + this.notify(state); + } + }; + + try { + const offer = await pc.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }); + await pc.setLocalDescription(offer); + + const response = await fetch(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/sdp', + ...buildAuthHeader(this.token), + }, + body: offer.sdp, + signal: this.abortController.signal, + }); + if (!response.ok) { + throw new Error(`WHEP request failed: ${response.status}`); + } + const answerSdp = await response.text(); + await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + pc.getReceivers().forEach((receiver) => { + if ('playoutDelayHint' in receiver) { + receiver.playoutDelayHint = 0; + } + }); + await this.video.play().catch(() => {}); + this.notify('playing'); + } catch (err) { + this.notify('error', err.message); + this.stop(); + throw err; + } + } + + stop() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + if (this.pc) { + this.pc.getSenders().forEach((sender) => sender.track?.stop()); + this.pc.getReceivers().forEach((receiver) => receiver.track?.stop()); + this.pc.close(); + this.pc = null; + } + if (this.video) { + this.video.srcObject = null; + } + this.notify('stopped'); + } +} diff --git a/webui/src/main.jsx b/webui/src/main.jsx new file mode 100644 index 00000000..16163c0f --- /dev/null +++ b/webui/src/main.jsx @@ -0,0 +1,26 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter, Route, Routes } from 'react-router-dom' +import './index.css' +import App from './App.jsx' +import { SocketProvider } from './context/SocketContext.jsx' +import { SessionProvider } from './context/SessionContext.jsx' +import { TelemetryProvider } from './context/TelemetryContext.jsx' +import SpectatorApp from './spectate/SpectatorApp.jsx' + +createRoot(document.getElementById('root')).render( + + + + + + + } /> + } /> + + + + + + , +) diff --git a/webui/src/spectate/SpectatorApp.jsx b/webui/src/spectate/SpectatorApp.jsx new file mode 100644 index 00000000..d79a579c --- /dev/null +++ b/webui/src/spectate/SpectatorApp.jsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; +import { useSpectatorMode } from '../hooks/useSpectatorMode.js'; +import { useTelemetryFrames } from '../context/TelemetryContext.jsx'; +import { useVideoRequests } from '../hooks/useVideoRequests.js'; +import VideoTile from '../components/VideoTile.jsx'; + +function SpectatorStatus({ connected, ready, rosterCount }) { + return ( +
+ + {connected ? 'Connected' : 'Disconnected'} + + + Spectator mode {ready ? 'active' : 'pending…'} + + + Rovers: {rosterCount} + +
+ ); +} + +function LogStream({ logs }) { + return ( +
+
+

Server logs

+ latest {logs.length} +
+
+ {logs.length === 0 ? ( +

No log entries yet.

+ ) : ( + logs + .slice() + .reverse() + .map((entry) => ( +
+ {entry.timestamp}{' '} + [{entry.level}]{' '} + {entry.label && [{entry.label}]}{' '} + {entry.message} +
+ )) + )} +
+
+ ); +} + +function formatSensors(sensors = {}) { + return Object.entries(sensors).map(([key, value]) => ({ + key, + value: typeof value === 'object' ? JSON.stringify(value) : value, + })); +} + +function RoverSpectatorCard({ rover, frame, videoInfo }) { + const sensorList = formatSensors(frame?.sensors); + return ( +
+
+

{rover.name}

+

+ {frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'} +

+
+ +
+

Telemetry

+ {sensorList.length === 0 ? ( +

No sensor data yet.

+ ) : ( +
+ {sensorList.map(({ key, value }) => ( +
+
{key}
+
{value}
+
+ ))} +
+ )} +
+
+ ); +} + +export default function SpectatorApp() { + const { connected, session, logs } = useSession(); + const spectatorReady = useSpectatorMode(); + const frames = useTelemetryFrames(); + const roster = session?.roster ?? []; + const videoSources = useVideoRequests(roster.map((rover) => rover.id)); + + const status = useMemo( + () => ({ connected, ready: spectatorReady, rosterCount: roster.length }), + [connected, spectatorReady, roster.length], + ); + + return ( +
+
+
+

Spectator Console

+

Live rover telemetry & logs. This view is read-only.

+ +
+
+ {roster.length === 0 ? ( +

No rovers registered.

+ ) : ( + roster.map((rover) => ( + + )) + )} +
+ +
+
+ ); +} diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js new file mode 100644 index 00000000..dca8ba02 --- /dev/null +++ b/webui/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/webui/vite.config.js b/webui/vite.config.js new file mode 100644 index 00000000..16e25075 --- /dev/null +++ b/webui/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + outDir: '../server/public', + emptyOutDir: true, + }, +})