mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
@@ -9,3 +9,5 @@ node_modules/
|
|||||||
.vscode/
|
.vscode/
|
||||||
config.h
|
config.h
|
||||||
robots.json
|
robots.json
|
||||||
|
roverd-dummy
|
||||||
|
server/config.yaml
|
||||||
|
|||||||
@@ -1,18 +1,87 @@
|
|||||||
# Multi Roomba Rover
|
# Multi Roomba Rover
|
||||||
|
|
||||||
a remake of my RoombaRover project with a decentralized and embedded approach
|
a remake of my RoombaRover project with a decentralized and embedded approach
|
||||||
|
supports multiple roombas
|
||||||
|
|
||||||
on each roomba:
|
on each roomba:
|
||||||
- an esp32
|
- a raspberry pi zero 2 w
|
||||||
- a level shifter
|
- a raspberry pi camera
|
||||||
- DONT FORGET THE BRC PIN PULSE
|
- roomba's serial port hooked up to the built in serial port on the raspberry pi
|
||||||
- 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 #?
|
|
||||||
|
|
||||||
UDP:
|
pi provisioning:
|
||||||
one control stream out to the esp32
|
- enable serial port
|
||||||
one raw data sensor stream in form the esp32
|
- 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://<server>: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://<server>:9000?streamid=#!::r=<name>,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://<base>/<rover-id>/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.
|
||||||
|
|||||||
@@ -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
|
||||||
Vendored
+20
@@ -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
|
||||||
Vendored
+20
@@ -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
|
||||||
BIN
Binary file not shown.
@@ -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://<server-host>:9000?streamid=#!::r=<name>,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://<server-host>:9000?streamid=#!::r=<name>,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 <action> <media.service>` (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://<server>: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.
|
||||||
@@ -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://<server>: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<id>[a-z0-9_-]+)$":
|
||||||
|
source: publisher
|
||||||
|
sourceOnDemand: no
|
||||||
|
sourceProtocol: srt
|
||||||
|
readBufferCount: 512
|
||||||
|
webrtcEnable: yes
|
||||||
|
webrtcMaxPlayoutDelay: 0
|
||||||
|
alwaysRemuxWhep: no
|
||||||
|
```
|
||||||
|
- Pis publish to `srt://<server>: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-<id>`, 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 `<id>`.
|
||||||
|
2. Issues a `POST` to `<mediamtx-host>/<rover-id>/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://<server>:9000?streamid=#!::r=<name>,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-<id>` stream with zero manual edits.
|
||||||
+29
-38
@@ -1,40 +1,38 @@
|
|||||||
## esp32 firmware
|
## multi roomba rover
|
||||||
- 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
|
|
||||||
|
|
||||||
## esp32 -> server communication
|
a website where people can control multiple irobot create 2 robots in real time 100% responsively
|
||||||
- one UDP stream to the esp32 for controlling the roomba
|
with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera
|
||||||
- might look like this:
|
a central nodejs control server will tell the raspberry pis what to do with the roomba
|
||||||
- left wheel speed
|
|
||||||
- right wheel speed
|
## the pi side (roomba side)
|
||||||
- OI mode
|
- pi's onboard UART is hooked up to the roomba's serial port
|
||||||
- seek dock?
|
- another GPIO pin connected to the roomba's BRC pin
|
||||||
- blasts out at a constant rate from the server for each roomba
|
- pull it low for one second every minute to keep the roomba awake
|
||||||
- the esp32 will listen, and follow the latest command that it sees
|
- streams the rpi camera over webRTC with mediamtx
|
||||||
- one UDP stream from the esp32 to the server for sending sensor data frames and other telemetry
|
- streams roomba sensor group 100 to the server
|
||||||
- one full frame of sensor data per datagram
|
- sensor streaming is required and important, but is allowed to falter sometimes
|
||||||
- send raw sensor data, the server will decode it
|
- listens for roomba commands from the server
|
||||||
- add other telemetry from the esp32, like signal strength, etc.
|
- commands NEED to happen
|
||||||
- maybe use this stream as a sign that the esp32 is still running healthily?
|
- 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
|
## nodejs server
|
||||||
- KISS
|
- KISS
|
||||||
- decode the sensor data from each roomba
|
- decode the sensor data from each roomba
|
||||||
- can support multiple roombas connected from the ground up
|
- 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
|
- but the server DOES have to exist for testing
|
||||||
- IS the web server, hosts an entire static folder for the web UI
|
- IS the web server, hosts an entire static folder for the web UI
|
||||||
|
|
||||||
@@ -58,10 +56,3 @@
|
|||||||
- everything modular
|
- everything modular
|
||||||
- everything easy to read, understand, and work on
|
- everything easy to read, understand, and work on
|
||||||
- comment where you think is best to describe whats going 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.
|
|
||||||
@@ -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
|
||||||
Executable
+144
@@ -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> Path to the roverd binary (default: dist/roverd)
|
||||||
|
-c, --config <path> 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"
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[env:esp32s3]
|
|
||||||
platform = espressif32
|
|
||||||
board = esp32-s3-devkitc-1
|
|
||||||
monitor_speed = 115200
|
|
||||||
+17
@@ -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
|
||||||
@@ -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://<base>/<roverId>/whep
|
||||||
|
whepBaseUrl: "http://192.168.0.86:8889"
|
||||||
@@ -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');
|
||||||
Executable
+130
@@ -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" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=mediaMTX WebRTC Server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=$TARGET_USER
|
||||||
|
Group=$TARGET_USER
|
||||||
|
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
||||||
|
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Multi-Roomba Rover control server
|
||||||
|
After=network-online.target mediamtx.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=$TARGET_USER
|
||||||
|
Group=$TARGET_USER
|
||||||
|
WorkingDirectory=$SERVER_DIR
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||||
|
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
||||||
|
|
||||||
|
echo "[5/6] Enabling services..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now mediamtx.service
|
||||||
|
systemctl enable --now multirover.service
|
||||||
|
systemctl restart mediamtx.service
|
||||||
|
systemctl restart multirover.service
|
||||||
|
|
||||||
|
echo "[6/6] Done."
|
||||||
|
echo
|
||||||
|
echo "Services installed:"
|
||||||
|
echo " mediamtx.service (WebRTC fan-out)"
|
||||||
|
echo " multirover.service (Node.js control server)"
|
||||||
|
echo
|
||||||
|
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
|
||||||
|
logLevel: info
|
||||||
|
|
||||||
|
api: yes
|
||||||
|
apiAddress: 0.0.0.0:9997
|
||||||
|
metrics: yes
|
||||||
|
metricsAddress: 0.0.0.0:9998
|
||||||
|
pprof: no
|
||||||
|
pprofAddress: 127.0.0.1:9999
|
||||||
|
|
||||||
|
rtsp: no
|
||||||
|
rtmp: no
|
||||||
|
hls: no
|
||||||
|
|
||||||
|
webrtc: yes
|
||||||
|
webrtcLocalUDPAddress: :8189
|
||||||
|
webrtcLocalTCPAddress: :8189
|
||||||
|
webrtcAdditionalHosts: []
|
||||||
|
webrtcICEServers2: []
|
||||||
|
|
||||||
|
srt: yes
|
||||||
|
srtAddress: :9000
|
||||||
|
|
||||||
|
authMethod: http
|
||||||
|
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
||||||
|
authHTTPExclude:
|
||||||
|
- action: publish
|
||||||
|
- action: api
|
||||||
|
- action: metrics
|
||||||
|
- action: pprof
|
||||||
|
|
||||||
|
paths:
|
||||||
|
all:
|
||||||
|
source: publisher
|
||||||
|
sourceOnDemand: no
|
||||||
|
# webrtcMaxPlayoutDelay: 0
|
||||||
|
# alwaysRemuxWhep: no
|
||||||
Generated
+1614
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>webui</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BvgUoEqO.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DwQ38q3M.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const api = process.env.MEDIAMTX_API || 'http://127.0.0.1:9997';
|
||||||
|
|
||||||
|
function fetchJSON(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(api + path, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const list = await fetchJSON('/v3/paths/list');
|
||||||
|
if (!list.items || !list.items.length) {
|
||||||
|
console.log('No active paths');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.items.forEach((item) => {
|
||||||
|
console.log(
|
||||||
|
`${item.name.padEnd(12)} ready=${item.ready} tracks=${item.tracks.join(',') || 'none'} bytes=${item.bytesReceived}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('check-media failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
const passwordFromArg = process.argv[2];
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
console.log(`Password: ${password}`);
|
||||||
|
console.log(`Hash: ${hash}`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error hashing password:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordFromArg) {
|
||||||
|
hashPassword(passwordFromArg);
|
||||||
|
} else {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question('Password to hash: ', (answer) => {
|
||||||
|
rl.close();
|
||||||
|
hashPassword(answer);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: process.env.PORT || 8080,
|
||||||
|
staticDir: path.join(__dirname, '..', '..', 'public'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(config.staticDir));
|
||||||
|
app.get('/spectate', (req, res) => {
|
||||||
|
res.sendFile(path.join(config.staticDir, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
|
module.exports = { app, httpServer };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const { Server: SocketIOServer } = require('socket.io');
|
||||||
|
const { httpServer } = require('./http');
|
||||||
|
|
||||||
|
const io = new SocketIOServer(httpServer, {
|
||||||
|
cors: { origin: '*' },
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = io;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const sinks = new Set();
|
||||||
|
|
||||||
|
function notifySinks(level, label, args) {
|
||||||
|
if (!sinks.size) return;
|
||||||
|
const entry = {
|
||||||
|
level,
|
||||||
|
label: label || null,
|
||||||
|
args,
|
||||||
|
message: args.map((value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (err) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}).join(' '),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
sinks.forEach((sink) => {
|
||||||
|
try {
|
||||||
|
sink(entry);
|
||||||
|
} catch (err) {
|
||||||
|
// avoid recursive logging
|
||||||
|
console.error(entry.timestamp, '[ERROR]', '[logger]', 'Log sink failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stamp(level, label, args) {
|
||||||
|
const fields = [new Date().toISOString(), `[${level}]`];
|
||||||
|
if (label) {
|
||||||
|
fields.push(`[${label}]`);
|
||||||
|
}
|
||||||
|
notifySinks(level, label, args);
|
||||||
|
return [...fields, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseLogger(label) {
|
||||||
|
return {
|
||||||
|
info: (...args) => console.log(...stamp('INFO', label, args)),
|
||||||
|
warn: (...args) => console.warn(...stamp('WARN', label, args)),
|
||||||
|
error: (...args) => console.error(...stamp('ERROR', label, args)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSink(fn) {
|
||||||
|
if (typeof fn !== 'function') return () => {};
|
||||||
|
sinks.add(fn);
|
||||||
|
return () => sinks.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseLogger(),
|
||||||
|
child: (label) => baseLogger(label),
|
||||||
|
registerSink,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { httpServer } = require('./http');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const roverWSS = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
httpServer.on('upgrade', (req, socket, head) => {
|
||||||
|
if (req.url.startsWith('/rover')) {
|
||||||
|
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
roverWSS.emit('connection', ws, req);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roverWSS.on('connection', () => {
|
||||||
|
logger.info('Rover websocket connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = roverWSS;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
|
||||||
|
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
|
||||||
|
|
||||||
|
let cachedConfig;
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||||
|
cachedConfig = yaml.load(file);
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
const HEADER = 0x13;
|
||||||
|
const CHARGING_STATE = {
|
||||||
|
0: 'not charging',
|
||||||
|
1: 'reconditioning charging',
|
||||||
|
2: 'full charging',
|
||||||
|
3: 'trickle charging',
|
||||||
|
4: 'waiting',
|
||||||
|
5: 'charging fault',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OI_MODES = {
|
||||||
|
0: 'off',
|
||||||
|
1: 'passive',
|
||||||
|
2: 'safe',
|
||||||
|
3: 'full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP100_LAYOUT = [
|
||||||
|
{ id: 7, key: 'bumpsAndWheelDrops', bytes: 1, parser: parseBumps },
|
||||||
|
{ id: 8, key: 'wall', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 9, key: 'cliffLeft', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 10, key: 'cliffFrontLeft', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 11, key: 'cliffFrontRight', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 12, key: 'cliffRight', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 13, key: 'virtualWall', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 14, key: 'wheelOvercurrents', bytes: 1, parser: parseWheelCurrents },
|
||||||
|
{ id: 15, key: 'dirtDetect', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 16, key: 'dirtDetectLeft', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 17, key: 'infraredCharacterOmni', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 18, key: 'buttons', bytes: 1, parser: parseButtons },
|
||||||
|
{ id: 19, key: 'distanceMm', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 20, key: 'angleDeg', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 21, key: 'chargingState', bytes: 1, parser: parseChargingState },
|
||||||
|
{ id: 22, key: 'voltageMv', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 23, key: 'currentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 24, key: 'batteryTemperatureC', bytes: 1, parser: parseInt },
|
||||||
|
{ id: 25, key: 'batteryChargeMah', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 26, key: 'batteryCapacityMah', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 27, key: 'wallSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 28, key: 'cliffLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 29, key: 'cliffFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 30, key: 'cliffFrontRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 31, key: 'cliffRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 32, key: 'chargingSourcesAvailable', bytes: 1, parser: parseChargeSources },
|
||||||
|
{ id: 33, key: 'chargingSourcesReserved', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 34, key: 'chargingSources', bytes: 1, parser: parseChargeSources },
|
||||||
|
{ id: 35, key: 'oiMode', bytes: 1, parser: parseOiMode },
|
||||||
|
{ id: 36, key: 'songNumber', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 37, key: 'songPlaying', bytes: 1, parser: parseBool },
|
||||||
|
{ id: 38, key: 'streamPacketCount', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 39, key: 'requestedVelocity', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 40, key: 'requestedRadius', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 41, key: 'requestedRightVelocity', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 42, key: 'requestedLeftVelocity', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 43, key: 'encoderCountsLeft', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 44, key: 'encoderCountsRight', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 45, key: 'lightBumper', bytes: 1, parser: parseLightBumper },
|
||||||
|
{ id: 46, key: 'lightBumpLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 47, key: 'lightBumpFrontLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 48, key: 'lightBumpCenterLeftSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 49, key: 'lightBumpCenterRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 50, key: 'lightBumpFrontRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 51, key: 'lightBumpRightSignal', bytes: 2, parser: parseUInt },
|
||||||
|
{ id: 52, key: 'infraredCharacterLeft', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 53, key: 'infraredCharacterRight', bytes: 1, parser: parseUInt },
|
||||||
|
{ id: 54, key: 'wheelLeftCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 55, key: 'wheelRightCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 56, key: 'mainBrushCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 57, key: 'sideBrushCurrentMa', bytes: 2, parser: parseInt },
|
||||||
|
{ id: 58, key: 'stasis', bytes: 1, parser: parseBool },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUP100_TOTAL = GROUP100_LAYOUT.reduce((sum, spec) => sum + spec.bytes, 0);
|
||||||
|
|
||||||
|
const TOP_LEVEL_PACKETS = {
|
||||||
|
100: GROUP100_TOTAL,
|
||||||
|
21: 1,
|
||||||
|
34: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSensorFrame(base64Data) {
|
||||||
|
if (!base64Data) return null;
|
||||||
|
const buf = Buffer.from(base64Data, 'base64');
|
||||||
|
if (buf.length < 4 || buf[0] !== HEADER) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nBytes = buf[1];
|
||||||
|
if (buf.length < nBytes + 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = buf.slice(2, 2 + nBytes);
|
||||||
|
const checksum = buf[2 + nBytes];
|
||||||
|
if (!validateChecksum(buf.slice(0, 2 + nBytes + 1), checksum)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const decoded = {};
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < payload.length) {
|
||||||
|
const packetId = payload[offset++];
|
||||||
|
const size = TOP_LEVEL_PACKETS[packetId];
|
||||||
|
if (!size || offset + size > payload.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const segment = payload.slice(offset, offset + size);
|
||||||
|
offset += size;
|
||||||
|
if (packetId === 100) {
|
||||||
|
Object.assign(decoded, decodeGroup100(segment));
|
||||||
|
} else if (packetId === 21 && decoded.chargingState == null) {
|
||||||
|
decoded.chargingState = parseChargingState(segment);
|
||||||
|
} else if (packetId === 34 && decoded.chargingSources == null) {
|
||||||
|
decoded.chargingSources = parseChargeSources(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeGroup100(buf) {
|
||||||
|
if (buf.length !== GROUP100_TOTAL) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const values = {};
|
||||||
|
let offset = 0;
|
||||||
|
for (const spec of GROUP100_LAYOUT) {
|
||||||
|
const slice = buf.slice(offset, offset + spec.bytes);
|
||||||
|
offset += spec.bytes;
|
||||||
|
try {
|
||||||
|
values[spec.key] = spec.parser ? spec.parser(slice) : parseUInt(slice);
|
||||||
|
} catch (err) {
|
||||||
|
values[spec.key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBool(buf) {
|
||||||
|
return Boolean(buf[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUInt(buf) {
|
||||||
|
return buf.readUIntBE(0, buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInt(buf) {
|
||||||
|
return buf.readIntBE(0, buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBumps(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
return {
|
||||||
|
bumpRight: Boolean(value & 0x01),
|
||||||
|
bumpLeft: Boolean(value & 0x02),
|
||||||
|
wheelDropRight: Boolean(value & 0x04),
|
||||||
|
wheelDropLeft: Boolean(value & 0x08),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWheelCurrents(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
return {
|
||||||
|
sideBrush: Boolean(value & 0x01),
|
||||||
|
mainBrush: Boolean(value & 0x04),
|
||||||
|
rightWheel: Boolean(value & 0x08),
|
||||||
|
leftWheel: Boolean(value & 0x10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_LABELS = ['clean', 'spot', 'dock', 'minute', 'hour', 'day', 'schedule', 'clock'];
|
||||||
|
function parseButtons(buf) {
|
||||||
|
const v = buf[0];
|
||||||
|
const result = {};
|
||||||
|
BUTTON_LABELS.forEach((label, idx) => {
|
||||||
|
result[label] = Boolean(v & (1 << idx));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChargingState(buf) {
|
||||||
|
const code = buf[0];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
label: CHARGING_STATE[code] || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChargeSources(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
return {
|
||||||
|
internalCharger: Boolean(value & 0x01),
|
||||||
|
homeBase: Boolean(value & 0x02),
|
||||||
|
raw: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOiMode(buf) {
|
||||||
|
const code = buf[0];
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
label: OI_MODES[code] || 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIGHT_BUMPER_LABELS = ['left', 'frontLeft', 'centerLeft', 'centerRight', 'frontRight', 'right'];
|
||||||
|
function parseLightBumper(buf) {
|
||||||
|
const value = buf[0];
|
||||||
|
const obj = {};
|
||||||
|
LIGHT_BUMPER_LABELS.forEach((label, idx) => {
|
||||||
|
obj[label] = Boolean(value & (1 << idx));
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateChecksum(frame, checksum) {
|
||||||
|
let sum = 0;
|
||||||
|
for (const byte of frame) {
|
||||||
|
sum = (sum + byte) & 0xff;
|
||||||
|
}
|
||||||
|
return (sum & 0xff) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseSensorFrame,
|
||||||
|
CHARGING_STATE,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
info: '#2196f3',
|
||||||
|
success: '#4caf50',
|
||||||
|
warn: '#f0b651',
|
||||||
|
error: '#e53935',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||||
|
const payload = {
|
||||||
|
color: color || COLORS.info,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
ts,
|
||||||
|
};
|
||||||
|
io.emit('alert', payload);
|
||||||
|
io.emit('alert:new', { id: `${ts}-${Math.random().toString(36).slice(2)}`, ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
COLORS,
|
||||||
|
sendAlert,
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('assignment');
|
||||||
|
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||||
|
const { roleEvents, getRole, isAdmin } = require('./roleService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
|
||||||
|
const socketRefs = new Map(); // socketId -> socket
|
||||||
|
const assignments = new Map(); // socketId -> roverId
|
||||||
|
const waiting = new Set(); // socketIds waiting for placement
|
||||||
|
const assignmentEvents = new EventEmitter();
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socketRefs.set(socket.id, socket);
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
socketRefs.delete(socket.id);
|
||||||
|
unassignSocket(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket, role }) => {
|
||||||
|
if (!socket || !socket.id) return;
|
||||||
|
if (role === 'user') {
|
||||||
|
assignSocket(socket);
|
||||||
|
} else {
|
||||||
|
unassignSocket(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modeEvents.on('change', (mode) => {
|
||||||
|
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||||
|
// release non-admin drivers
|
||||||
|
for (const [socketId, roverId] of assignments.entries()) {
|
||||||
|
const socket = socketRefs.get(socketId);
|
||||||
|
if (socket && !isAdmin(socket)) {
|
||||||
|
releaseAssignment(socket, roverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reassignWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
|
||||||
|
if (locked) {
|
||||||
|
reassignFromRover(roverId);
|
||||||
|
} else {
|
||||||
|
reassignWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('rover', ({ action }) => {
|
||||||
|
if (action === 'removed' || action === 'upsert') {
|
||||||
|
reassignWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function assignSocket(socket) {
|
||||||
|
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// avoid double assignment
|
||||||
|
if (assignments.has(socket.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = pickRover();
|
||||||
|
if (!target) {
|
||||||
|
waiting.add(socket.id);
|
||||||
|
logger.info('No rover available, user waiting', socket.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
roverManager.requestControl(target.id, socket, { allowUser: true });
|
||||||
|
assignments.set(socket.id, target.id);
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
logger.info('Assigned user to rover', socket.id, target.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to assign user', err.message);
|
||||||
|
waiting.add(socket.id);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unassignSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
const roverId = assignments.get(socket.id);
|
||||||
|
if (roverId) {
|
||||||
|
roverManager.releaseControl(roverId, socket);
|
||||||
|
assignments.delete(socket.id);
|
||||||
|
logger.info('Unassigned socket from rover', socket.id, roverId);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassignFromRover(roverId) {
|
||||||
|
for (const [socketId, rid] of assignments.entries()) {
|
||||||
|
if (rid !== roverId) continue;
|
||||||
|
const socket = socketRefs.get(socketId);
|
||||||
|
if (!socket) {
|
||||||
|
assignments.delete(socketId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
roverManager.releaseControl(rid, socket);
|
||||||
|
assignments.delete(socketId);
|
||||||
|
assignSocket(socket);
|
||||||
|
assignmentEvents.emit('update', socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reassignWaiting() {
|
||||||
|
for (const socketId of Array.from(waiting)) {
|
||||||
|
const socket = socketRefs.get(socketId);
|
||||||
|
if (socket) {
|
||||||
|
assignSocket(socket);
|
||||||
|
} else {
|
||||||
|
waiting.delete(socketId);
|
||||||
|
}
|
||||||
|
assignmentEvents.emit('update', socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseAssignment(socket, roverId) {
|
||||||
|
roverManager.releaseControl(roverId, socket);
|
||||||
|
assignments.delete(socket.id);
|
||||||
|
waiting.add(socket.id);
|
||||||
|
logger.info('Released assignment back to queue', socket.id, roverId);
|
||||||
|
assignmentEvents.emit('update', socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRover() {
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||||
|
if (!rover || rover.locked) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a.drivers.size - b.drivers.size);
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAssignment(socketId) {
|
||||||
|
const assignedRoverId = assignments.get(socketId) || null;
|
||||||
|
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
|
const waitingIndex = waiting.has(socketId) ? Array.from(waiting).indexOf(socketId) : -1;
|
||||||
|
const roverId = assignedRoverId || adminRoverId || null;
|
||||||
|
const waitingStatus = waiting.has(socketId);
|
||||||
|
return {
|
||||||
|
roverId,
|
||||||
|
status: assignedRoverId ? 'assigned' : adminRoverId ? 'admin' : waitingStatus ? 'waiting' : null,
|
||||||
|
queuePosition: waitingIndex >= 0 ? waitingIndex + 1 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assignmentEvents,
|
||||||
|
describeAssignment,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('authService');
|
||||||
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
const { clearLockdownTimer } = require('./lockdownGuard');
|
||||||
|
const { setRole } = require('./roleService');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const admins = config.admins || [];
|
||||||
|
|
||||||
|
function findAdmin(username) {
|
||||||
|
return admins.find((admin) => admin.username === username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(username, password) {
|
||||||
|
const admin = findAdmin(username);
|
||||||
|
if (!admin) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
const ok = await bcrypt.compare(password, admin.password_hash);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error('Invalid credentials');
|
||||||
|
}
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(socket) {
|
||||||
|
return socket?.data?.role === 'admin' || socket?.data?.role === 'lockdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockdownAdmin(socket) {
|
||||||
|
return socket?.data?.role === 'lockdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const requestedRole = socket.handshake?.query?.role;
|
||||||
|
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
||||||
|
setRole(socket, initialRole);
|
||||||
|
logger.info('Socket connected with role', socket.id, initialRole);
|
||||||
|
socket.emit('auth:role', { role: initialRole });
|
||||||
|
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
const admin = await authenticate(username, password);
|
||||||
|
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||||
|
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||||
|
setRole(socket, role);
|
||||||
|
socket.emit('auth:role', { role });
|
||||||
|
clearLockdownTimer(socket);
|
||||||
|
logger.info('Login success', socket.id, role);
|
||||||
|
cb({ success: true, role: socket.data.role });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Login failed', socket.id, err.message);
|
||||||
|
cb({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||||
|
if (role === 'spectator' || role === 'user') {
|
||||||
|
setRole(socket, role);
|
||||||
|
socket.emit('auth:role', { role });
|
||||||
|
logger.info('Role changed via client request', socket.id, role);
|
||||||
|
cb({ success: true, role });
|
||||||
|
} else {
|
||||||
|
cb({ error: 'Invalid role' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('role:set', handleRoleChange);
|
||||||
|
socket.on('session:setRole', handleRoleChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
authenticate,
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const logger = require('../globals/logger').child('commandService');
|
||||||
|
|
||||||
|
const pendingCommands = new Map(); // id -> { roverId }
|
||||||
|
|
||||||
|
function issueCommand(roverId, payload) {
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record || !record.ws) {
|
||||||
|
throw new Error('Rover offline');
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
const message = { ...payload, id };
|
||||||
|
record.ws.send(JSON.stringify(message));
|
||||||
|
pendingCommands.set(id, { roverId, ts: Date.now(), type: payload.type });
|
||||||
|
logger.info('Issued command', roverId, payload.type, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAck(msg) {
|
||||||
|
const pending = pendingCommands.get(msg.id);
|
||||||
|
if (!pending) return;
|
||||||
|
pendingCommands.delete(msg.id);
|
||||||
|
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
||||||
|
io.emit('commandAck', {
|
||||||
|
roverId: pending.roverId,
|
||||||
|
id: msg.id,
|
||||||
|
status: msg.status || 'ok',
|
||||||
|
error: msg.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
issueCommand,
|
||||||
|
handleAck,
|
||||||
|
};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
function handleCommand({ roverId, type, data } = {}, cb) {
|
||||||
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
|
try {
|
||||||
|
if (!roverId) {
|
||||||
|
throw new Error('roverId required');
|
||||||
|
}
|
||||||
|
if (!roverManager.canDrive(roverId, socket)) {
|
||||||
|
throw new Error('Not your turn or no control');
|
||||||
|
}
|
||||||
|
const payload = data ? { ...data } : {};
|
||||||
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
|
logger.info('Queued command', socket.id, roverId, type);
|
||||||
|
reply({ id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Command rejected', socket.id, err.message);
|
||||||
|
reply({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('command', handleCommand);
|
||||||
|
socket.on('command:issue', handleCommand);
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const { httpServer } = require('../globals/http');
|
||||||
|
const config = require('../globals/config');
|
||||||
|
const logger = require('../globals/logger').child('httpServer');
|
||||||
|
|
||||||
|
httpServer.listen(config.port, () => {
|
||||||
|
logger.info(`Server listening on :${config.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||||
|
const { isLockdownAdmin } = require('./roleService');
|
||||||
|
|
||||||
|
function disconnectForLockdown(socket) {
|
||||||
|
socket.emit('lockdown', { message: 'Server is in lockdown mode' });
|
||||||
|
socket.disconnect(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLockdownTimer(socket) {
|
||||||
|
if (socket?.data?.lockdownTimer) {
|
||||||
|
clearTimeout(socket.data.lockdownTimer);
|
||||||
|
socket.data.lockdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceLockdown() {
|
||||||
|
for (const socket of io.sockets.sockets.values()) {
|
||||||
|
if (!isLockdownAdmin(socket)) {
|
||||||
|
disconnectForLockdown(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
|
socket.data.lockdownTimer = setTimeout(() => {
|
||||||
|
if (!isLockdownAdmin(socket)) {
|
||||||
|
disconnectForLockdown(socket);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
socket.once('disconnect', () => clearLockdownTimer(socket));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
enforceLockdown,
|
||||||
|
disconnectForLockdown,
|
||||||
|
clearLockdownTimer,
|
||||||
|
};
|
||||||
|
|
||||||
|
modeEvents.on('change', (mode) => {
|
||||||
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
enforceLockdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const loggerRoot = require('../globals/logger');
|
||||||
|
const logger = loggerRoot.child('logStream');
|
||||||
|
|
||||||
|
const MAX_HISTORY = 200;
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
function pushEntry(entry) {
|
||||||
|
history.push(entry);
|
||||||
|
if (history.length > MAX_HISTORY) {
|
||||||
|
history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(entry) {
|
||||||
|
io.emit('log:entry', entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.emit('log:init', history);
|
||||||
|
}
|
||||||
|
|
||||||
|
loggerRoot.registerSink(({ level, label, message, timestamp }) => {
|
||||||
|
const entry = {
|
||||||
|
id: uuidv4(),
|
||||||
|
level,
|
||||||
|
label,
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
pushEntry(entry);
|
||||||
|
broadcast(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
logger.info('Hydrating log history for', socket.id);
|
||||||
|
hydrateSocket(socket);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
OPEN: 'open',
|
||||||
|
TURNS: 'turns',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
LOCKDOWN: 'lockdown',
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentMode = MODES.OPEN;
|
||||||
|
const modeEvents = new EventEmitter();
|
||||||
|
|
||||||
|
function canChangeMode(socket, nextMode) {
|
||||||
|
if (nextMode === MODES.LOCKDOWN) {
|
||||||
|
return isLockdownAdmin(socket);
|
||||||
|
}
|
||||||
|
return isAdmin(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(nextMode, socket) {
|
||||||
|
if (!Object.values(MODES).includes(nextMode)) {
|
||||||
|
throw new Error(`Unknown mode ${nextMode}`);
|
||||||
|
}
|
||||||
|
if (!canChangeMode(socket, nextMode)) {
|
||||||
|
throw new Error('Not authorized to change mode');
|
||||||
|
}
|
||||||
|
if (currentMode === nextMode) {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
currentMode = nextMode;
|
||||||
|
sendAlert({
|
||||||
|
color: COLORS.info,
|
||||||
|
title: 'Mode Changed',
|
||||||
|
message: `Server mode set to ${nextMode}`,
|
||||||
|
});
|
||||||
|
modeEvents.emit('change', currentMode);
|
||||||
|
io.emit('mode', { mode: currentMode });
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMode() {
|
||||||
|
return currentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MODES,
|
||||||
|
getMode,
|
||||||
|
setMode,
|
||||||
|
modeEvents,
|
||||||
|
};
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.emit('mode', { mode: currentMode });
|
||||||
|
socket.on('setMode', ({ mode }) => {
|
||||||
|
try {
|
||||||
|
setMode(mode, socket);
|
||||||
|
} catch (err) {
|
||||||
|
sendAlert({ color: COLORS.error, title: 'Mode change failed', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const roleEvents = new EventEmitter();
|
||||||
|
|
||||||
|
function getRole(socket) {
|
||||||
|
return socket?.data?.role || 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRole(socket, role) {
|
||||||
|
socket.data = socket.data || {};
|
||||||
|
if (socket.data.role === role) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.data.role = role;
|
||||||
|
roleEvents.emit('change', { socket, role });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(socket) {
|
||||||
|
const role = getRole(socket);
|
||||||
|
return role === 'admin' || role === 'lockdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockdownAdmin(socket) {
|
||||||
|
return getRole(socket) === 'lockdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getRole,
|
||||||
|
setRole,
|
||||||
|
isAdmin,
|
||||||
|
isLockdownAdmin,
|
||||||
|
roleEvents,
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const roverWSS = require('../globals/ws');
|
||||||
|
const logger = require('../globals/logger').child('roverConnection');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { handleAck } = require('./commandService');
|
||||||
|
|
||||||
|
function handleMessage(roverId, msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'hello':
|
||||||
|
roverManager.upsertRover(msg, this);
|
||||||
|
sendAlert({ color: COLORS.info, title: 'Rover Connected', message: roverId });
|
||||||
|
break;
|
||||||
|
case 'sensor':
|
||||||
|
roverManager.handleSensorFrame(roverId, msg);
|
||||||
|
break;
|
||||||
|
case 'event':
|
||||||
|
sendAlert({ color: COLORS.info, title: `${roverId} event`, message: msg.event });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roverWSS.on('connection', (ws) => {
|
||||||
|
let roverId = null;
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Invalid rover message', err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'hello') {
|
||||||
|
roverId = msg.name;
|
||||||
|
roverManager.upsertRover(msg, ws);
|
||||||
|
roverManager.broadcastRoster();
|
||||||
|
sendAlert({ color: COLORS.success, title: 'Rover Online', message: roverId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!roverId) return;
|
||||||
|
if (msg.type === 'sensor') {
|
||||||
|
roverManager.handleSensorFrame(roverId, msg);
|
||||||
|
} else if (msg.type === 'ack') {
|
||||||
|
handleAck(msg);
|
||||||
|
} else if (msg.type === 'event') {
|
||||||
|
sendAlert({ color: COLORS.info, title: `${roverId}`, message: msg.event });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (roverId) {
|
||||||
|
roverManager.removeRover(roverId);
|
||||||
|
sendAlert({ color: COLORS.warn, title: 'Rover Offline', message: roverId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('roverManager');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { parseSensorFrame } = require('../helpers/sensorDecoder');
|
||||||
|
const { MODES, getMode } = require('./modeManager');
|
||||||
|
const { isAdmin, roleEvents } = require('./roleService');
|
||||||
|
|
||||||
|
const rovers = new Map(); // roverId -> record
|
||||||
|
const socketToRovers = new Map(); // socketId -> Set(roverId)
|
||||||
|
const spectatorSockets = new Set();
|
||||||
|
const turnService = require('./turnService');
|
||||||
|
const managerEvents = new EventEmitter();
|
||||||
|
|
||||||
|
function ensureRecord(id) {
|
||||||
|
if (!rovers.has(id)) {
|
||||||
|
rovers.set(id, {
|
||||||
|
id,
|
||||||
|
meta: null,
|
||||||
|
ws: null,
|
||||||
|
lastSensor: null,
|
||||||
|
drivers: new Set(),
|
||||||
|
locked: false,
|
||||||
|
room: `rover:${id}`,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rovers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertRover(meta, ws) {
|
||||||
|
const id = meta.name || meta.id;
|
||||||
|
const record = ensureRecord(id);
|
||||||
|
record.meta = meta;
|
||||||
|
record.ws = ws;
|
||||||
|
record.lastSeen = Date.now();
|
||||||
|
rovers.set(id, record);
|
||||||
|
spectatorSockets.forEach((socketId) => {
|
||||||
|
const sock = io.sockets.sockets.get(socketId);
|
||||||
|
sock?.join(record.room);
|
||||||
|
});
|
||||||
|
managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
|
||||||
|
broadcastRoster();
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRover(id) {
|
||||||
|
const record = rovers.get(id);
|
||||||
|
if (!record) return;
|
||||||
|
rovers.delete(id);
|
||||||
|
turnService.cleanupRover(id);
|
||||||
|
spectatorSockets.forEach((socketId) => {
|
||||||
|
const sock = io.sockets.sockets.get(socketId);
|
||||||
|
sock?.leave(record.room);
|
||||||
|
});
|
||||||
|
broadcastRoster();
|
||||||
|
managerEvents.emit('rover', { roverId: id, action: 'removed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockRover(id, locked, actorSocket) {
|
||||||
|
const record = rovers.get(id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Unknown rover');
|
||||||
|
}
|
||||||
|
if (locked) {
|
||||||
|
record.locked = true;
|
||||||
|
sendAlert({ color: COLORS.warn, title: 'Rover Locked', message: `${id} locked by admin.` });
|
||||||
|
} else {
|
||||||
|
record.locked = false;
|
||||||
|
sendAlert({ color: COLORS.success, title: 'Rover Unlocked', message: `${id} unlocked.` });
|
||||||
|
}
|
||||||
|
broadcastRoster();
|
||||||
|
managerEvents.emit('lock', { roverId: id, locked: record.locked });
|
||||||
|
return record.locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoster() {
|
||||||
|
return Array.from(rovers.values()).map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.meta?.name || record.id,
|
||||||
|
battery: record.meta?.battery,
|
||||||
|
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
||||||
|
media: record.meta?.media,
|
||||||
|
locked: record.locked,
|
||||||
|
lastSeen: record.lastSeen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastRoster() {
|
||||||
|
io.emit('rovers', getRoster());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSensorFrame(roverId, frame) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
record.lastSeen = Date.now();
|
||||||
|
const decoded = parseSensorFrame(frame.data);
|
||||||
|
record.lastSensor = { raw: frame, decoded };
|
||||||
|
io.to(record.room).emit('sensorFrame', {
|
||||||
|
roverId,
|
||||||
|
frame,
|
||||||
|
sensors: decoded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSocket(socket) {
|
||||||
|
const joined = socketToRovers.get(socket.id);
|
||||||
|
if (!joined) {
|
||||||
|
disableSpectator(socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const roverId of joined) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (record) {
|
||||||
|
record.drivers.delete(socket.id);
|
||||||
|
}
|
||||||
|
turnService.driverRemoved(roverId, socket.id);
|
||||||
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
|
||||||
|
}
|
||||||
|
socketToRovers.delete(socket.id);
|
||||||
|
disableSpectator(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestControl(roverId, socket, options = {}) {
|
||||||
|
const { force = false, allowUser = false } = options;
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Unknown rover');
|
||||||
|
}
|
||||||
|
if (!allowUser && !isAdmin(socket)) {
|
||||||
|
throw new Error('Only admins can request control');
|
||||||
|
}
|
||||||
|
if (record.locked && !isAdmin(socket) && !allowUser) {
|
||||||
|
throw new Error('Rover locked');
|
||||||
|
}
|
||||||
|
const mode = getMode();
|
||||||
|
if (!allowUser && mode === MODES.ADMIN && !isAdmin(socket)) {
|
||||||
|
throw new Error('Admins only');
|
||||||
|
}
|
||||||
|
if (!allowUser && mode === MODES.LOCKDOWN && !isAdmin(socket)) {
|
||||||
|
throw new Error('Server in lockdown');
|
||||||
|
}
|
||||||
|
record.drivers.add(socket.id);
|
||||||
|
if (!socketToRovers.has(socket.id)) {
|
||||||
|
socketToRovers.set(socket.id, new Set());
|
||||||
|
}
|
||||||
|
socketToRovers.get(socket.id).add(roverId);
|
||||||
|
socket.join(record.room);
|
||||||
|
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
|
||||||
|
socket.emit('controlGranted', { roverId });
|
||||||
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
|
||||||
|
sendAlert({
|
||||||
|
color: COLORS.success,
|
||||||
|
title: 'Control Granted',
|
||||||
|
message: `${socket.id} now driving ${roverId}`,
|
||||||
|
});
|
||||||
|
return { roverId, room: record.room };
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseControl(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return;
|
||||||
|
record.drivers.delete(socket.id);
|
||||||
|
const joined = socketToRovers.get(socket.id);
|
||||||
|
if (joined) {
|
||||||
|
joined.delete(roverId);
|
||||||
|
if (joined.size === 0) {
|
||||||
|
socketToRovers.delete(socket.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.leave(record.room);
|
||||||
|
turnService.driverRemoved(roverId, socket.id);
|
||||||
|
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDriver(roverId, socket) {
|
||||||
|
const record = rovers.get(roverId);
|
||||||
|
if (!record) return false;
|
||||||
|
return record.drivers.has(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDrive(roverId, socket) {
|
||||||
|
return turnService.canDrive(roverId, socket) || isAdmin(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoversForSocket(socketId) {
|
||||||
|
const joined = socketToRovers.get(socketId);
|
||||||
|
if (!joined || joined.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.from(joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryRoverForSocket(socketId) {
|
||||||
|
const joined = socketToRovers.get(socketId);
|
||||||
|
if (!joined || joined.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const iterator = joined.values();
|
||||||
|
const first = iterator.next();
|
||||||
|
return first.done ? null : first.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertRover,
|
||||||
|
removeRover,
|
||||||
|
lockRover,
|
||||||
|
getRoster,
|
||||||
|
broadcastRoster,
|
||||||
|
handleSensorFrame,
|
||||||
|
requestControl,
|
||||||
|
releaseControl,
|
||||||
|
removeSocket,
|
||||||
|
isDriver,
|
||||||
|
canDrive,
|
||||||
|
enableSpectator,
|
||||||
|
disableSpectator,
|
||||||
|
rovers,
|
||||||
|
managerEvents,
|
||||||
|
getRoversForSocket,
|
||||||
|
getPrimaryRoverForSocket,
|
||||||
|
};
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket, role }) => {
|
||||||
|
if (role === 'spectator') {
|
||||||
|
enableSpectator(socket);
|
||||||
|
} else {
|
||||||
|
disableSpectator(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.emit('rovers', getRoster());
|
||||||
|
if (socket.data?.role === 'spectator') {
|
||||||
|
enableSpectator(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
|
||||||
|
try {
|
||||||
|
const targetId = roverId || Array.from(rovers.keys())[0];
|
||||||
|
if (!targetId) {
|
||||||
|
throw new Error('No rovers available');
|
||||||
|
}
|
||||||
|
logger.info('Request control', socket.id, targetId, { force });
|
||||||
|
requestControl(targetId, socket, { force: Boolean(force) });
|
||||||
|
socket.emit('controlGranted', { roverId: targetId });
|
||||||
|
cb({ success: true, roverId: targetId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Request control failed', socket.id, err.message);
|
||||||
|
sendAlert({ color: COLORS.warn, title: 'Control denied', message: err.message });
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReleaseControl({ roverId } = {}, cb = () => {}) {
|
||||||
|
if (!roverId) {
|
||||||
|
cb({ error: 'roverId required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('Release control', socket.id, roverId);
|
||||||
|
releaseControl(roverId, socket);
|
||||||
|
cb({ success: true, roverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Not authorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
lockRover(roverId, locked);
|
||||||
|
logger.info('Lock state changed', roverId, locked);
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Lock change failed', roverId, err.message);
|
||||||
|
sendAlert({ color: COLORS.error, title: 'Lock failed', message: err.message });
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubscribeAll(_, cb = () => {}) {
|
||||||
|
if (socket.data?.role !== 'spectator') {
|
||||||
|
cb({ error: 'Spectator role required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('Spectator subscribing to all rovers', socket.id);
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
socket.join(record.room);
|
||||||
|
}
|
||||||
|
cb({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('requestControl', handleRequestControl);
|
||||||
|
socket.on('session:requestControl', handleRequestControl);
|
||||||
|
socket.on('releaseControl', handleReleaseControl);
|
||||||
|
socket.on('session:releaseControl', handleReleaseControl);
|
||||||
|
socket.on('lockRover', handleLockToggle);
|
||||||
|
socket.on('session:lockRover', handleLockToggle);
|
||||||
|
socket.on('subscribeAll', handleSubscribeAll);
|
||||||
|
socket.on('session:subscribeAll', handleSubscribeAll);
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
logger.info('Socket disconnected', socket.id);
|
||||||
|
removeSocket(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function enableSpectator(socket) {
|
||||||
|
if (!socket?.id || spectatorSockets.has(socket.id)) return;
|
||||||
|
spectatorSockets.add(socket.id);
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
socket.join(record.room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableSpectator(socket) {
|
||||||
|
if (!socket?.id || !spectatorSockets.has(socket.id)) return;
|
||||||
|
spectatorSockets.delete(socket.id);
|
||||||
|
for (const record of rovers.values()) {
|
||||||
|
socket.leave(record.room);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('sessionService');
|
||||||
|
const { getRole, roleEvents } = require('./roleService');
|
||||||
|
const { getMode, modeEvents } = require('./modeManager');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { managerEvents } = roverManager;
|
||||||
|
const assignmentService = require('./assignmentService');
|
||||||
|
const { getActiveDrivers, turnEvents } = require('./turnService');
|
||||||
|
|
||||||
|
function buildSession(socket) {
|
||||||
|
return {
|
||||||
|
socketId: socket?.id || null,
|
||||||
|
role: getRole(socket),
|
||||||
|
mode: getMode(),
|
||||||
|
roster: roverManager.getRoster(),
|
||||||
|
assignment: assignmentService.describeAssignment(socket?.id || ''),
|
||||||
|
activeDrivers: getActiveDrivers(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
const payload = buildSession(socket);
|
||||||
|
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
||||||
|
socket.emit('session:sync', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAll() {
|
||||||
|
logger.info('Broadcasting session sync to all sockets');
|
||||||
|
io.sockets.sockets.forEach((socket) => syncSocket(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
logger.info('New socket connected', socket.id);
|
||||||
|
syncSocket(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket }) => {
|
||||||
|
if (!socket) return;
|
||||||
|
logger.info('Role changed; syncing session', socket.id);
|
||||||
|
syncSocket(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
assignmentService.assignmentEvents.on('update', (socketId) => {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (socket) {
|
||||||
|
logger.info('Assignment update; syncing session', socketId);
|
||||||
|
syncSocket(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modeEvents.on('change', () => {
|
||||||
|
logger.info('Mode change detected; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
managerEvents.on('rover', () => {
|
||||||
|
logger.info('Rover roster change; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
managerEvents.on('lock', ({ roverId, locked }) => {
|
||||||
|
logger.info('Rover lock change', roverId, locked);
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
managerEvents.on('driver', ({ socketId }) => {
|
||||||
|
if (!socketId) return;
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (socket) {
|
||||||
|
logger.info('Driver assignment change; syncing session', socketId);
|
||||||
|
syncSocket(socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
turnEvents.on('activeDriver', () => {
|
||||||
|
logger.info('Active driver change; syncing all clients');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildSession,
|
||||||
|
syncSocket,
|
||||||
|
syncAll,
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const { sendAlert, COLORS } = require('./alertService');
|
||||||
|
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||||
|
|
||||||
|
const driverQueues = new Map(); // roverId -> { queue: [], current: socketId, timer: Timeout | null }
|
||||||
|
const activeDrivers = new Map();
|
||||||
|
const TURN_DURATION_MS = 60 * 1000;
|
||||||
|
const turnEvents = new EventEmitter();
|
||||||
|
|
||||||
|
function driverAdded(roverId, socketId, force) {
|
||||||
|
const queue = ensureQueue(roverId);
|
||||||
|
if (!queue.queue.includes(socketId)) {
|
||||||
|
queue.queue.push(socketId);
|
||||||
|
}
|
||||||
|
if (!queue.current || force) {
|
||||||
|
queue.current = socketId;
|
||||||
|
}
|
||||||
|
syncState(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverRemoved(roverId, socketId) {
|
||||||
|
const queue = driverQueues.get(roverId);
|
||||||
|
if (!queue) return;
|
||||||
|
queue.queue = queue.queue.filter((id) => id !== socketId);
|
||||||
|
if (queue.current === socketId) {
|
||||||
|
advanceTurn(roverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRover(roverId) {
|
||||||
|
const queue = driverQueues.get(roverId);
|
||||||
|
if (queue) {
|
||||||
|
clearTimeout(queue.timer);
|
||||||
|
}
|
||||||
|
driverQueues.delete(roverId);
|
||||||
|
activeDrivers.delete(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDrive(roverId, socket) {
|
||||||
|
if (!socket) return false;
|
||||||
|
const queue = driverQueues.get(roverId);
|
||||||
|
if (!queue || getMode() !== MODES.TURNS || queue.queue.length <= 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return activeDrivers.get(roverId) === socket.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureQueue(roverId) {
|
||||||
|
if (!driverQueues.has(roverId)) {
|
||||||
|
driverQueues.set(roverId, { queue: [], current: null, timer: null });
|
||||||
|
}
|
||||||
|
return driverQueues.get(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncState(roverId) {
|
||||||
|
const mode = getMode();
|
||||||
|
const queue = ensureQueue(roverId);
|
||||||
|
if (mode !== MODES.TURNS || queue.queue.length <= 1) {
|
||||||
|
queue.current = queue.queue[0] || null;
|
||||||
|
setActiveDriver(roverId, queue.current);
|
||||||
|
clearTimeout(queue.timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue.current) {
|
||||||
|
queue.current = queue.queue[0];
|
||||||
|
}
|
||||||
|
setActiveDriver(roverId, queue.current);
|
||||||
|
scheduleNextTurn(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextTurn(roverId) {
|
||||||
|
const queue = driverQueues.get(roverId);
|
||||||
|
if (!queue) return;
|
||||||
|
clearTimeout(queue.timer);
|
||||||
|
queue.timer = setTimeout(() => advanceTurn(roverId), TURN_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceTurn(roverId) {
|
||||||
|
const queue = driverQueues.get(roverId);
|
||||||
|
if (!queue) return;
|
||||||
|
if (queue.queue.length === 0) {
|
||||||
|
clearTimeout(queue.timer);
|
||||||
|
setActiveDriver(roverId, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode !== MODES.TURNS || queue.queue.length <= 1) {
|
||||||
|
queue.current = queue.queue[0] || null;
|
||||||
|
setActiveDriver(roverId, queue.current);
|
||||||
|
clearTimeout(queue.timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = queue.queue.findIndex((id) => id === queue.current);
|
||||||
|
const nextIdx = idx === -1 ? 0 : (idx + 1) % queue.queue.length;
|
||||||
|
queue.current = queue.queue[nextIdx];
|
||||||
|
setActiveDriver(roverId, queue.current);
|
||||||
|
sendAlert({ color: COLORS.info, title: 'Turn switch', message: `${queue.current} now controls ${roverId}` });
|
||||||
|
stopRover(roverId);
|
||||||
|
scheduleNextTurn(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveDriver(roverId, socketId) {
|
||||||
|
if (!socketId) {
|
||||||
|
activeDrivers.delete(roverId);
|
||||||
|
} else {
|
||||||
|
activeDrivers.set(roverId, socketId);
|
||||||
|
}
|
||||||
|
turnEvents.emit('activeDriver', { roverId, socketId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveDrivers() {
|
||||||
|
const map = {};
|
||||||
|
activeDrivers.forEach((socketId, roverId) => {
|
||||||
|
map[roverId] = socketId;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRover(roverId) {
|
||||||
|
try {
|
||||||
|
const { issueCommand } = require('./commandService');
|
||||||
|
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||||
|
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
|
||||||
|
} catch (err) {
|
||||||
|
// best effort; log elsewhere if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modeEvents.on('change', (mode) => {
|
||||||
|
driverQueues.forEach((_, roverId) => syncState(roverId));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
driverAdded,
|
||||||
|
driverRemoved,
|
||||||
|
cleanupRover,
|
||||||
|
canDrive,
|
||||||
|
getActiveDrivers,
|
||||||
|
turnEvents,
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
const { app } = require('../globals/http');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('videoAuth');
|
||||||
|
const videoSessions = require('./videoSessions');
|
||||||
|
const { getMode, MODES } = require('./modeManager');
|
||||||
|
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
|
||||||
|
function canView(socket) {
|
||||||
|
const mode = getMode();
|
||||||
|
if (!socket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
return isLockdownAdmin(socket);
|
||||||
|
}
|
||||||
|
if (mode === MODES.ADMIN) {
|
||||||
|
return isAdmin(socket);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/mediamtx/auth', (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const path = (body.path || '').replace(/^\//, '');
|
||||||
|
const sessionId = body.user;
|
||||||
|
const roverId = path;
|
||||||
|
|
||||||
|
if (!sessionId || !roverId) {
|
||||||
|
logger.warn('auth missing session or rover (session=%s path=%s)', sessionId, path);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = videoSessions.getSession(sessionId);
|
||||||
|
if (!info || info.roverId !== roverId) {
|
||||||
|
logger.warn('invalid session %s for rover %s', sessionId, roverId);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
const socket = io.sockets.sockets.get(info.socketId);
|
||||||
|
if (!socket) {
|
||||||
|
videoSessions.revokeSession(sessionId);
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
if (!canView(socket)) {
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
const role = getRole(socket);
|
||||||
|
if (role !== 'spectator' && !isAdmin(socket)) {
|
||||||
|
if (!roverManager.isDriver(roverId, socket)) {
|
||||||
|
return res.status(401).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).end();
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
|
||||||
|
const sessions = new Map(); // sessionId -> { socketId, roverId }
|
||||||
|
const socketSessions = new Map(); // socketId -> Set(sessionId)
|
||||||
|
|
||||||
|
function createSession(socket, roverId) {
|
||||||
|
const sessionId = uuidv4();
|
||||||
|
sessions.set(sessionId, { socketId: socket.id, roverId });
|
||||||
|
if (!socketSessions.has(socket.id)) {
|
||||||
|
socketSessions.set(socket.id, new Set());
|
||||||
|
}
|
||||||
|
socketSessions.get(socket.id).add(sessionId);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSession(sessionId) {
|
||||||
|
return sessions.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeSession(sessionId) {
|
||||||
|
const info = sessions.get(sessionId);
|
||||||
|
if (!info) return;
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
const bucket = socketSessions.get(info.socketId);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.delete(sessionId);
|
||||||
|
if (bucket.size === 0) {
|
||||||
|
socketSessions.delete(info.socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeBySocket(socketId) {
|
||||||
|
const bucket = socketSessions.get(socketId);
|
||||||
|
if (!bucket) return;
|
||||||
|
for (const sessionId of bucket) {
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
socketSessions.delete(socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeWhere(predicate) {
|
||||||
|
for (const [sessionId, info] of Array.from(sessions.entries())) {
|
||||||
|
if (predicate(info)) {
|
||||||
|
revokeSession(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('disconnect', () => revokeBySocket(socket.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
revokeSession,
|
||||||
|
revokeBySocket,
|
||||||
|
revokeWhere,
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('videoSocket');
|
||||||
|
const { getMode, MODES } = require('./modeManager');
|
||||||
|
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
|
||||||
|
const videoSessions = require('./videoSessions');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { loadConfig } = require('../helpers/configLoader');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const mediaConfig = config.media || {};
|
||||||
|
|
||||||
|
function buildWhepUrl(roverId) {
|
||||||
|
const base = mediaConfig.whepBaseUrl;
|
||||||
|
if (!base) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let origin;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(base);
|
||||||
|
origin = parsed.origin;
|
||||||
|
} catch (err) {
|
||||||
|
origin = base.replace(/\/.*$/, '');
|
||||||
|
}
|
||||||
|
const cleanBase = origin.replace(/\/$/, '');
|
||||||
|
const encodedId = encodeURIComponent(roverId);
|
||||||
|
return `${cleanBase}/${encodedId}/whep`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canView(socket, roverId) {
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mode === MODES.ADMIN && !isAdmin(socket)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const role = getRole(socket);
|
||||||
|
if (role === 'spectator' || isAdmin(socket)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return roverManager.isDriver(roverId, socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('video:request', ({ roverId } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!roverId) {
|
||||||
|
throw new Error('roverId required');
|
||||||
|
}
|
||||||
|
if (!roverManager.rovers.has(roverId)) {
|
||||||
|
throw new Error('Rover offline');
|
||||||
|
}
|
||||||
|
if (!canView(socket, roverId)) {
|
||||||
|
throw new Error('Not authorized for video');
|
||||||
|
}
|
||||||
|
const url = buildWhepUrl(roverId);
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Server video base URL missing');
|
||||||
|
}
|
||||||
|
const sessionId = videoSessions.createSession(socket, roverId);
|
||||||
|
cb({ url, token: sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('video request failed: %s', err.message);
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# server code structure:
|
||||||
|
The server's internal structure will be modular, and by modular i mean completely modular,
|
||||||
|
the modules will import what they need from other modules, and export what other modules will need from them.
|
||||||
|
the entrypoint file will contain nothing but a long list of `require('')`s for all of the modules in the proper order
|
||||||
|
|
||||||
|
for example, A service which automatically assigns a newly connected user to a roomba that isn't in use.
|
||||||
|
this service would import the roomba list from whatever other service contains it, and import the global socket.io server instance. It will add its own io.on('connection') to the socket.io instance, which contains the logic for assigning users to roombas.
|
||||||
|
|
||||||
|
- modular code structure
|
||||||
|
- one folder for each of these categories
|
||||||
|
- globals
|
||||||
|
- GLOBALS ARE: "static" parts of the server that don't contain any interactive logic.
|
||||||
|
- where the express, websocket, and socket.io instances will be
|
||||||
|
- other global things
|
||||||
|
- services
|
||||||
|
- SERVICES ARE: parts of the program that are part of the interaction pipeline.
|
||||||
|
- contains things like the roomba manager
|
||||||
|
- will also in the future contain other things like a discord bot, home assistant integration, etc
|
||||||
|
- anything with a large amount of controlling logic should be in here
|
||||||
|
- helpers
|
||||||
|
- HELPERS ARE: parts of the program that other modules only pull helper funcions or classes from.
|
||||||
|
- if a function or class is dedicated to a service, it should NOT be in a helper.
|
||||||
|
- contains passive helpers
|
||||||
|
- things like the logger system
|
||||||
|
- no "service" logic in here, only things that are passively pulled out and used inside other modules
|
||||||
|
- one entrypoint file that contains NOTHING but `require('')`s.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# server <-> web client logistics
|
||||||
|
|
||||||
|
I want each connected roomba to have a list of drivers. if a socket is not in this list, they are not allowed to drive the roomba. I will be adding admins, authentication, a turns system, and automatic roomba assignment later so it is important that we start with this system in place.
|
||||||
|
a roomba's list of drivers will be completely managed by the server, users should not be able to change driver lists, even in a hacky way.
|
||||||
|
|
||||||
|
Each user will also have to see sensor data from the roomba that they are controlling. I want it to be done in this way:
|
||||||
|
- for each connected roomba, there is a socket.io room where all of the sensor data is streamed out to clients
|
||||||
|
- the client will be added to the room, where they can see all of the active roomba's sensor data
|
||||||
|
- ALSO for future use, it needs to work properly if a socket is subscribed to all rooms, in the future there will be a spectator page which can view all of the roombas at once.
|
||||||
|
|
||||||
|
I want there to be a ground up system where I can set the entire service to four different modes:
|
||||||
|
- open (anyone can drive, roombas are assigned randomly. if they are all full, two people will be controlling the same roomba)
|
||||||
|
- turns (anyone can drive, roombas are assigned randomly. if they are all full, each person gets one minute on their selected roomba)
|
||||||
|
- admin (only authenticated admins can log in, anyone can still view but no one is allowed to drive but admins)
|
||||||
|
- lockdown (only "lockdown" admins can view or drive. no one else can view, not even spectators).
|
||||||
|
only admins can change modes.
|
||||||
|
|
||||||
|
do NOT implement any authentication stuff yet, just add a stub service with places set up to put the auth. logic
|
||||||
|
|
||||||
|
# web client code structure:
|
||||||
|
- the same as the server's code structure
|
||||||
|
- all ES6
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# general idea:
|
||||||
|
- very compact UI
|
||||||
|
- utilitarian
|
||||||
|
- function before pretty
|
||||||
|
- tiny text, too.
|
||||||
|
- no large margins or padding. very little wasted space
|
||||||
|
- up to 1 tailwind unit unless otherwise needed
|
||||||
|
- black background, gray cards, white or gray text
|
||||||
|
- tailwind
|
||||||
|
- no title or bar at the top, vertical screen space is precious on all layouts
|
||||||
|
- alerts should be moved to a tiny toast popup that will show at the top center of the screen
|
||||||
|
|
||||||
|
# three different layouts for the driver page
|
||||||
|
- desktop
|
||||||
|
- rover video central, no title or anything above it
|
||||||
|
- overlayed on rover video (HUD like in a video game)
|
||||||
|
- build the HUD into the video tile
|
||||||
|
- because everywhere the video is, the HUD should be too
|
||||||
|
- shows a dot that blinks on each sensor frame
|
||||||
|
- shows wheel drops and bumpers
|
||||||
|
- shows a big "loud" warning overlay during an overcurrent
|
||||||
|
- simple, easy to understand battery bar UNDERNEATH the video:
|
||||||
|
- takes in battery full, warn, and urgent values
|
||||||
|
- treat full as 100% and warn as 0%
|
||||||
|
- make the bar flash red when its 0% or lower
|
||||||
|
- on left side of rover video:
|
||||||
|
- sensor data, visualized.
|
||||||
|
- battery charge out of capacity (1763/2068)
|
||||||
|
- charging status
|
||||||
|
- OI mode
|
||||||
|
- battery voltage (in volts not mv)
|
||||||
|
- battery current
|
||||||
|
- a teeny show of the raw data (because we get it on the client and it looks cool)
|
||||||
|
- on right side of rover video
|
||||||
|
- a simple, easy control panel with the following actions:
|
||||||
|
- a button that runs the start OI, dock, then full commands (in that order)
|
||||||
|
- also contains a little status of whether or not the rover is in "driving mode" (OI in full mode)
|
||||||
|
- a button that tells the rover to seek dock
|
||||||
|
- explains that you should be straight in front of, and about a foot from the dock for a successful attempt
|
||||||
|
- contains two status indicators:
|
||||||
|
- docked / not docked (homebase true)
|
||||||
|
- charging / not charging (when chargingstate isn't "not charging" the rover is charging)
|
||||||
|
- below the upper control and video row
|
||||||
|
- center:
|
||||||
|
- a place for the room camera (not implemented yet)
|
||||||
|
- left:
|
||||||
|
- admin login and controls
|
||||||
|
- right:
|
||||||
|
- logs
|
||||||
|
- mobile in portrait mode
|
||||||
|
- rover video at the top, with HUD
|
||||||
|
- just below:
|
||||||
|
- joystick with buttons for aux. motor controls
|
||||||
|
- have a button to run all aux motors forward at max speed
|
||||||
|
- also have buttons to run motors forward / backward individually
|
||||||
|
- probably use some premade react joystick
|
||||||
|
- scroll down more to see:
|
||||||
|
- the same simple mode control buttons from the desktop layout
|
||||||
|
- also needs to have the admin login and controls
|
||||||
|
- needs to have all the functionality as the desktop page
|
||||||
|
- mobile in landscape mode
|
||||||
|
- made for videogame-like control of the rover
|
||||||
|
- control buttons and aux motors on the left
|
||||||
|
- rover video in the middle
|
||||||
|
- joystick on the right
|
||||||
|
- again, scroll down to see more
|
||||||
|
- also has to do everything
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>webui</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4242
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "webui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-joystick-component": "^6.2.1",
|
||||||
|
"react-router-dom": "^7.9.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"vite": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import TelemetryPanel from './components/TelemetryPanel.jsx';
|
||||||
|
import DrivePanel from './components/DrivePanel.jsx';
|
||||||
|
import AlertFeed from './components/AlertFeed.jsx';
|
||||||
|
import AdminPanel from './components/AdminPanel.jsx';
|
||||||
|
import MobileControls, { MobileJoystick, AuxMotorControls } from './components/MobileControls.jsx';
|
||||||
|
import { DriveControlProvider } from './context/DriveControlContext.jsx';
|
||||||
|
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
|
||||||
|
import LogPanel from './components/LogPanel.jsx';
|
||||||
|
import AuthPanel from './components/AuthPanel.jsx';
|
||||||
|
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
|
||||||
|
import RightPaneTabs from './components/RightPaneTabs.jsx';
|
||||||
|
import ModeGateOverlay from './components/ModeGateOverlay.jsx';
|
||||||
|
import SessionSnapshot from './components/SessionSnapshot.jsx';
|
||||||
|
|
||||||
|
function useLayoutMode() {
|
||||||
|
const [mode, setMode] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return 'desktop';
|
||||||
|
return window.innerWidth >= 1024
|
||||||
|
? 'desktop'
|
||||||
|
: window.innerWidth > window.innerHeight
|
||||||
|
? 'mobile-landscape'
|
||||||
|
: 'mobile-portrait';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function updateMode() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const { innerWidth, innerHeight } = window;
|
||||||
|
if (innerWidth >= 1024) {
|
||||||
|
setMode('desktop');
|
||||||
|
} else if (innerWidth > innerHeight) {
|
||||||
|
setMode('mobile-landscape');
|
||||||
|
} else {
|
||||||
|
setMode('mobile-portrait');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateMode();
|
||||||
|
window.addEventListener('resize', updateMode);
|
||||||
|
return () => window.removeEventListener('resize', updateMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopLayout({ layout }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-1">
|
||||||
|
<DriverVideoPanel />
|
||||||
|
<RightPaneTabs layout={layout} />
|
||||||
|
</section>
|
||||||
|
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||||
|
{/* <AdminPanel /> */}
|
||||||
|
<LogPanel />
|
||||||
|
<SessionSnapshot />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobilePortraitLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<DriverVideoPanel />
|
||||||
|
<MobileControls />
|
||||||
|
<DrivePanel />
|
||||||
|
<TelemetryPanel />
|
||||||
|
<AuthPanel />
|
||||||
|
<AdminPanel />
|
||||||
|
<RoomCameraPanel />
|
||||||
|
<LogPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileLandscapeLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<section className="grid grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<AuxMotorControls />
|
||||||
|
</div>
|
||||||
|
<DriverVideoPanel />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<MobileJoystick />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<RoomCameraPanel />
|
||||||
|
<DrivePanel />
|
||||||
|
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<AuthPanel />
|
||||||
|
<AdminPanel />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<LogPanel />
|
||||||
|
<TelemetryPanel />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const layout = useLayoutMode();
|
||||||
|
const renderedLayout =
|
||||||
|
layout === 'desktop'
|
||||||
|
? <DesktopLayout layout={layout} />
|
||||||
|
: layout === 'mobile-landscape'
|
||||||
|
? <MobileLandscapeLayout />
|
||||||
|
: <MobilePortraitLayout />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-slate-50">
|
||||||
|
<DriveControlProvider>
|
||||||
|
<main className="flex w-full flex-col gap-1 px-1 py-1 text-base">{renderedLayout}</main>
|
||||||
|
<AlertFeed />
|
||||||
|
<ModeGateOverlay />
|
||||||
|
</DriveControlProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,104 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{ key: 'open', label: 'Open' },
|
||||||
|
{ key: 'turns', label: 'Turns' },
|
||||||
|
{ key: 'admin', label: 'Admin' },
|
||||||
|
{ key: 'lockdown', label: 'Lockdown' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const { session, lockRover, setMode, requestControl } = useSession();
|
||||||
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
|
const [lockStates, setLockStates] = useState({});
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
session?.role === 'admin' ||
|
||||||
|
session?.role === 'lockdown' ||
|
||||||
|
session?.role === 'lockdown-admin';
|
||||||
|
|
||||||
|
const currentMode = session?.mode ?? 'open';
|
||||||
|
|
||||||
|
const handleLockToggle = async (roverId, locked) => {
|
||||||
|
try {
|
||||||
|
await lockRover(roverId, locked);
|
||||||
|
setLockStates((prev) => ({ ...prev, [roverId]: locked }));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeChange = async (event) => {
|
||||||
|
const mode = event.target.value;
|
||||||
|
try {
|
||||||
|
await setMode(mode);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceControl = async (roverId) => {
|
||||||
|
try {
|
||||||
|
await requestControl(roverId, { force: true });
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lockMap = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
roster.forEach((rover) => {
|
||||||
|
map[rover.id] = lockStates[rover.id] ?? rover.locked;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [roster, lockStates]);
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<div className="flex items-center justify-between gap-1 text-sm">
|
||||||
|
<span>Admin controls</span>
|
||||||
|
<select
|
||||||
|
value={currentMode}
|
||||||
|
onChange={handleModeChange}
|
||||||
|
className="rounded-sm bg-black/40 px-1 py-1 text-sm text-slate-100"
|
||||||
|
>
|
||||||
|
{MODES.map((mode) => (
|
||||||
|
<option key={mode.key} value={mode.key}>
|
||||||
|
{mode.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-1 text-sm">
|
||||||
|
{roster.map((rover) => (
|
||||||
|
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-100">{rover.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
|
||||||
|
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||||
|
>
|
||||||
|
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleForceControl(rover.id)}
|
||||||
|
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||||
|
>
|
||||||
|
Force
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||||
|
<p className="text-xs text-slate-400">Manual OI commands</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{manualTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => sendOiCommand(tab.key)}
|
||||||
|
disabled={!canControl}
|
||||||
|
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||||
|
<p className="text-xs text-slate-400">Sensor stream</p>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
{sensorButtons.map((btn) => (
|
||||||
|
<button
|
||||||
|
key={btn.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSensorToggle(btn.enable)}
|
||||||
|
disabled={!canControl}
|
||||||
|
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!canControl && <p className="mt-1 text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
||||||
|
</section>
|
||||||
|
<AuthPanel />
|
||||||
|
<AdminPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
|
||||||
|
{visible.map((toast) => (
|
||||||
|
<AlertToast key={toast.key} alert={toast.alert} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={`pointer-events-auto rounded-sm px-1 py-1 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
|
||||||
|
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
|
||||||
|
<p className="text-sm text-white">{alert.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<p className="text-sm text-slate-400">Admin login</p>
|
||||||
|
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
|
||||||
|
<input
|
||||||
|
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-sm bg-slate-200 px-1 py-1 text-sm font-semibold text-black disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Logging in…' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-1 flex gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole('user')}
|
||||||
|
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||||
|
>
|
||||||
|
Driver
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole('spectator')}
|
||||||
|
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||||
|
>
|
||||||
|
Spectator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||||
|
<span>Drive control</span>
|
||||||
|
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<ActionCard
|
||||||
|
title="Start Driving"
|
||||||
|
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
|
||||||
|
statuses={[{ label: drivingMode ? 'Ready!' : 'Not Ready!', active: drivingMode }]}
|
||||||
|
tone="emerald"
|
||||||
|
onClick={runStartDockFull}
|
||||||
|
disabled={!roverId}
|
||||||
|
/>
|
||||||
|
<ActionCard
|
||||||
|
title="Dock and Charge"
|
||||||
|
description="Line the rover up about a foot from the dock, then trigger an automatic approach."
|
||||||
|
statuses={[
|
||||||
|
{ label: docked ? 'Docked!' : 'Not Docked!', active: docked },
|
||||||
|
{ label: charging ? 'Charging!' : 'Not Charging!', active: charging },
|
||||||
|
]}
|
||||||
|
tone="indigo"
|
||||||
|
onClick={seekDock}
|
||||||
|
disabled={!roverId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
|
||||||
|
const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full rounded-lg ${baseColor} px-1 py-1 text-left text-white disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
<p className="text-base font-semibold">{title}</p>
|
||||||
|
<p className="text-sm text-white/90">{description}</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<span
|
||||||
|
key={status.label}
|
||||||
|
className={`rounded-full px-1 py-0.5 text-xs font-semibold ${
|
||||||
|
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{footnote && <p className="mt-1 text-xs text-emerald-50/80">{footnote}</p>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1">
|
||||||
|
{roverId ? (
|
||||||
|
<div className="w-full">
|
||||||
|
<VideoTile
|
||||||
|
sessionInfo={info}
|
||||||
|
label={roverId}
|
||||||
|
muted={false}
|
||||||
|
telemetryFrame={frame}
|
||||||
|
batteryConfig={batteryConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<p className="text-sm text-slate-400">Help</p>
|
||||||
|
<ul className="mt-1 space-y-1 text-sm">
|
||||||
|
{tips.map((tip) => (
|
||||||
|
<li key={tip} className="rounded-sm bg-black/30 px-1 py-1 text-slate-200">
|
||||||
|
{tip}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
export default function LogPanel() {
|
||||||
|
const { logs } = useSession();
|
||||||
|
return (
|
||||||
|
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||||
|
<span>Server logs</span>
|
||||||
|
<span>{logs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-64 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-xs">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p>No logs yet.</p>
|
||||||
|
) : (
|
||||||
|
logs
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((entry) => (
|
||||||
|
<div key={entry.id} className="mb-1">
|
||||||
|
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||||
|
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||||
|
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||||
|
<span>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||||
|
<div className="text-xs text-slate-400">Joystick</div>
|
||||||
|
<div className="mt-1 flex items-center justify-center">
|
||||||
|
<Joystick
|
||||||
|
size={120}
|
||||||
|
baseColor="#0f172a"
|
||||||
|
stickColor="#38bdf8"
|
||||||
|
throttle={75}
|
||||||
|
move={handleMove}
|
||||||
|
stop={handleStop}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
Drag to drive. Release to stop sending drive commands.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopMotors}
|
||||||
|
disabled={disabled}
|
||||||
|
className="mt-1 w-full rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Panic Stop
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||||
|
<div className="text-xs text-slate-400">Aux motors</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runAllForward}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
All forward
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopAll}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Stop all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-2 gap-1 text-[0.75rem]">
|
||||||
|
{auxButtons.map((btn) => (
|
||||||
|
<button
|
||||||
|
key={btn.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && setAuxMotors(btn.values)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded-sm bg-black/30 px-1 py-1 text-slate-200 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileControlsStack() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 sm:flex-row">
|
||||||
|
<div className="flex-1">
|
||||||
|
<MobileJoystick />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<AuxMotorControls />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MobileJoystick, AuxMotorControls };
|
||||||
@@ -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 (
|
||||||
|
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-2 py-2">
|
||||||
|
<div className="w-full max-w-md space-y-2 rounded-lg bg-[#141821] p-2 text-slate-100 shadow-2xl">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-lg font-semibold">{details.title}</p>
|
||||||
|
<p className="text-sm text-slate-300">{details.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-black/40 p-1">
|
||||||
|
<AuthPanel />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||||
|
changes or after a successful login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-1 rounded-sm px-1 py-1 text-xs font-semibold ${
|
||||||
|
active ? 'bg-slate-100 text-slate-900' : 'bg-black/30 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomControlsPlaceholder() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||||
|
<p className="text-xs text-slate-400">Room controls</p>
|
||||||
|
<p className="text-sm text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RightPaneTabs({ layout }) {
|
||||||
|
const [active, setActive] = useState('telemetry');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-sm bg-[#1a1d23] p-1 text-base text-slate-100">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<TabButton active={active === 'telemetry'} onClick={() => setActive('telemetry')}>
|
||||||
|
Rover Controls
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={active === 'room'} onClick={() => setActive('room')}>
|
||||||
|
Room Controls
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={active === 'advanced'} onClick={() => setActive('advanced')}>
|
||||||
|
Advanced
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={active === 'help'} onClick={() => setActive('help')}>
|
||||||
|
Help
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{active === 'telemetry' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<DrivePanel />
|
||||||
|
<TelemetryPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active === 'room' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<RoomCameraPanel />
|
||||||
|
<RoomControlsPlaceholder />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{active === 'advanced' && <AdvancedSettings />}
|
||||||
|
{active === 'help' && <HelpPanel layout={layout} />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default function RoomCameraPanel() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[8rem] rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<p className="text-center text-sm text-slate-400">Room camera</p>
|
||||||
|
<p className="mt-1 text-center">Feed placeholder. Wire upcoming room cam here.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="rounded-sm bg-[#242a32] p-1 text-xs text-slate-100">
|
||||||
|
<p className="text-sm text-slate-400">Session snapshot</p>
|
||||||
|
<pre className="mt-1 h-64 overflow-y-auto bg-black/40 p-1 text-[0.7rem] text-lime-300">{payload}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
<span>{connected ? 'online' : 'offline'}</span>
|
||||||
|
<span> · role {session?.role || 'unknown'}</span>
|
||||||
|
<span> · mode {session?.mode || '--'}</span>
|
||||||
|
{updated && <span> · sensors {updated}</span>}
|
||||||
|
<span> · driver {driverLabel}</span>
|
||||||
|
</div>
|
||||||
|
{!roverId ? (
|
||||||
|
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p>
|
||||||
|
) : !frame ? (
|
||||||
|
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-0.5">
|
||||||
|
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
|
||||||
|
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||||
|
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||||
|
<Metric label="Voltage" value={formatMetric(voltage)} />
|
||||||
|
<Metric label="Current" value={formatMetric(current)} />
|
||||||
|
<Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} />
|
||||||
|
<Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-0.5">
|
||||||
|
<CliffBar value={sensors.cliffLeftSignal} />
|
||||||
|
<CliffBar value={sensors.cliffFrontLeftSignal} />
|
||||||
|
<CliffBar value={sensors.cliffFrontRightSignal} />
|
||||||
|
<CliffBar value={sensors.cliffRightSignal} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{rawSnippet && (
|
||||||
|
// console.log('rawSnippet:', rawSnippet),
|
||||||
|
<pre className="mt-1 text-wrap break-words p-1 text-xs text-lime-300 bg-gray-700">
|
||||||
|
{rawSnippet}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<div className="mt-1">
|
||||||
|
<p className="text-sm text-slate-400">Rovers</p>
|
||||||
|
{roster.length === 0 ? (
|
||||||
|
// if there ARE NO rovers
|
||||||
|
<p className="text-sm text-slate-500">No roster data.</p>
|
||||||
|
) : (
|
||||||
|
// if there ARE rovers
|
||||||
|
<ul className="mt-1 space-y-1 text-sm">
|
||||||
|
{roster.map((rover) => (
|
||||||
|
<li key={rover.id} className="flex items-center justify-between gap-1">
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-200">{rover.name}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{rover.locked ? 'locked' : 'free'} · {rover.lastSeen ? 'seen' : 'unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRequest(rover.id)}
|
||||||
|
disabled={pending[rover.id]}
|
||||||
|
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{pending[rover.id] ? '...' : 'request'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="w-1/4 h-6 bg-gray-700 rounded-sm overflow-hidden">
|
||||||
|
<div className="w-full bg-amber-400" style={{ height }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-700 rounded-sm p-0.5 text-sm">
|
||||||
|
{label}: {value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user