Compare commits

..
Author SHA1 Message Date
legop3 b19bec7ee5 no persist admin status 2026-01-23 04:18:57 -05:00
legop3 987ef29f30 ban system 2026-01-23 04:12:38 -05:00
165 changed files with 3796 additions and 12623 deletions
-1
View File
@@ -15,5 +15,4 @@ server/package-lock.json
package-lock.json package-lock.json
server/data/discord-guilds.json server/data/discord-guilds.json
server/data/community-goal.json server/data/community-goal.json
server/data/admin-reason.json
server/data server/data
+86 -4
View File
@@ -1,7 +1,89 @@
# Multi Roomba Rover # Multi Roomba Rover
A system for controlling create 2 compatible roombas through a webpage.
Docs coming "soon" a remake of my RoombaRover project with a decentralized and embedded approach
supports multiple roombas
## Basic installation on each roomba:
- - a raspberry pi zero 2 w
- a raspberry pi camera
- roomba's serial port hooked up to the built in serial port on the raspberry pi
pi provisioning:
- enable serial port
- disable wifi powersave
- disable bluetooth
## Repo layout
- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets.
- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`.
- `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the SRT video publisher that streams camera feeds to the server).
- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX).
## Quick start
```bash
# build the Pi agent (armv7)
cd pi/roverd
mkdir -p ../../dist
make pi-build
# start the server + UI
cd ../../server
npm install
npm run start
```
Need a fake rover or multi-rover testing without hardware? Use the dummy build:
```bash
cd pi/roverd
mkdir -p ../../dist
make dummy
./../../dist/roverd-dummy -config ./roverd.sample.yaml
```
The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as youd 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 UIs 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 cant 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 rovers ID, and (optionally) override `media.publishUrl` if your control server isnt `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 servers 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` (set it to the URL you expose publicly, e.g. `https://rover.otter.land/video`). If your proxy cant rewrite paths, create the `/video` location there and add a custom nginx snippet to rewrite `/video/<rover>/whep` to `/<>/whep` before forwarding to mediaMTX. 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.
Room cameras now use JPEG snapshots (4 fps) instead of WHEP. Each entry in `roomCameras` must include a `url` pointing at the snapshot endpoint; the server polls and relays frames over socket.io with the same access rules as before.
### 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 sockets 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 rovers `ready` state and byte counters so you can instantly spot publish issues.
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+112
View File
@@ -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`). If your reverse proxy adds prefixes (like `/video/<name>`), use its rewrite options so the rover keeps publishing to plain `<name>`.
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`; keep proxy-only path prefixes out of this URL so every rover keeps the same simple stream name.
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 youre 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.
+9 -71
View File
@@ -1,85 +1,23 @@
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi") # Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
options snd_rpi_googlevoicehat_soundcard index=0 options snd_rpi_googlevoicehat_soundcard index=0
# Mix multiple playback clients in software with a fixed low-cost format. # Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
pcm.dmixer { pcm.softvol {
type dmix
ipc_key 1024
ipc_perm 0666
slave {
pcm "hw:0,0"
format S16_LE
rate 16000
channels 1
period_time 0
period_size 1024
buffer_size 4096
}
}
# TTS volume control (used by default playback path).
pcm.tts_softvol {
type softvol type softvol
slave.pcm "dmixer" slave.pcm "plughw:0,0"
control { control {
name "TTSMaster" name "SoftMaster"
card 0 card 0
} }
min_dB -60.0 min_dB -51.0
max_dB 12.0 max_dB 0.0
} }
# Horn volume control. # Defaults: playback through softvol, capture raw on the HAT
pcm.horn_softvol {
type softvol
slave.pcm "dmixer"
control {
name "HornMaster"
card 0
}
min_dB -60.0
max_dB 12.0
}
# Forwarded audio volume control.
pcm.forward_softvol {
type softvol
slave.pcm "dmixer"
control {
name "ForwardMaster"
card 0
}
min_dB -60.0
max_dB 12.0
}
# Per-source playback PCMs.
pcm.tts {
type plug
slave.pcm "tts_softvol"
}
pcm.horn {
type plug
slave.pcm "horn_softvol"
}
pcm.forward {
type plug
slave.pcm "forward_softvol"
}
# Capture alias used by rover config defaults.
pcm.rovermic {
type plug
slave.pcm "hw:0,0"
}
# Defaults: TTS direct playback + raw capture on the HAT.
pcm.!default { pcm.!default {
type asym type asym
playback.pcm "tts" playback.pcm "softvol"
capture.pcm "rovermic" capture.pcm "hw:0,0"
} }
ctl.!default { ctl.!default {
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
set +H
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Environment file ${ENV_FILE} missing; cannot start audio forward listener" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
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
if command -v aplay >/dev/null 2>&1; then
APLAY_BIN_PATH="$(command -v aplay)"
else
echo "aplay not found; install it via apt install alsa-utils." >&2
exit 1
fi
LAST_FFMPEG_STATUS="unknown"
LAST_APLAY_STATUS="unknown"
run_pipeline() {
set +e
"${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-flags low_delay \
-analyzeduration 200k \
-probesize 32k \
-i "${AUDIO_FORWARD_URL}" \
-vn \
-ac 1 \
-ar 16000 \
-f s16le \
pipe:1 \
| "${APLAY_BIN_PATH}" \
-q \
-D "${PLAYBACK_DEVICE}" \
-t raw \
-f S16_LE \
-r 16000 \
-c 1
local rc=$?
local -a statuses=("${PIPESTATUS[@]}")
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
set -e
return "${rc}"
}
trap 'kill 0 2>/dev/null' EXIT INT TERM
while true; do
if run_pipeline; then
exit 0
fi
echo "Audio forward listener exited ffmpeg=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
sleep 2
done
+4 -12
View File
@@ -20,8 +20,8 @@ Options:
The script must run from the repository root and as root (sudo). It will: The script must run from the repository root and as root (sudo). It will:
* create system users/groups if needed * create system users/groups if needed
* install /usr/local/bin/roverd and /etc/roverd.yaml * install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video/audio helpers and systemd units * install /usr/local/bin/video-publisher and its systemd unit
* enable roverd.service and media publisher/listener services * enable roverd.service and video-publisher.service
USAGE USAGE
} }
@@ -210,20 +210,14 @@ log "Installed video-publisher systemd unit"
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
log "Installed audio-only publisher helper + systemd unit" log "Installed audio-only publisher helper + systemd unit"
# Install audio-forward listener assets
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
log "Installed audio-forward listener helper + systemd unit"
install -d -o roverd -g roverd /var/lib/roverd install -d -o roverd -g roverd /var/lib/roverd
cat > /var/lib/roverd/video.env <<'ENV' cat > /var/lib/roverd/video.env <<'ENV'
# Managed by roverd; placeholder values will be overwritten at runtime. # 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=10&mode=caller&transtype=live&pkt_size=1316 PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
VIDEO_BITRATE=2000000 VIDEO_BITRATE=2000000
AUDIO_ENABLE=0 AUDIO_ENABLE=0
AUDIO_DEVICE=hw:0,0 AUDIO_DEVICE=hw:0,0
AUDIO_PLAYBACK_DEVICE=forward
AUDIO_RATE=48000 AUDIO_RATE=48000
AUDIO_CHANNELS=2 AUDIO_CHANNELS=2
ENV ENV
@@ -250,15 +244,13 @@ systemctl daemon-reload
systemctl enable roverd.service systemctl enable roverd.service
systemctl enable video-publisher.service systemctl enable video-publisher.service
systemctl enable audio-only-publisher.service systemctl enable audio-only-publisher.service
systemctl enable audio-forward-listener.service
if [[ $CONFIG_EXISTS -eq 1 ]]; then if [[ $CONFIG_EXISTS -eq 1 ]]; then
systemctl restart roverd.service systemctl restart roverd.service
systemctl restart video-publisher.service systemctl restart video-publisher.service
systemctl restart audio-only-publisher.service systemctl restart audio-only-publisher.service
systemctl restart audio-forward-listener.service log "Restarted roverd + video/audio publishers"
log "Restarted roverd + media publishers/listener"
else else
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener" log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
fi fi
log "Install complete" log "Install complete"
+1 -2
View File
@@ -11,10 +11,9 @@ build:
pi-build: pi-build:
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/hornverifier ./cmd/hornverifier
dummy: dummy:
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
clean: clean:
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier $(BIN_DIR)/hornverifier rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier
-117
View File
@@ -1,117 +0,0 @@
package roverd
import (
"fmt"
"math"
"os/exec"
)
type AudioLevels struct {
HornGain float64
TTSGain float64
ForwardGain float64
}
func clampAudioGain(v float64) float64 {
if v < 0 {
return 0
}
if v > 4 {
return 4
}
return v
}
func normalizeAudioLevels(v AudioLevels) AudioLevels {
v.HornGain = clampAudioGain(v.HornGain)
v.TTSGain = clampAudioGain(v.TTSGain)
v.ForwardGain = clampAudioGain(v.ForwardGain)
return v
}
func (c *WSClient) getAudioLevels() AudioLevels {
c.audioMu.RLock()
defer c.audioMu.RUnlock()
return c.audioLevels
}
func (c *WSClient) setAudioLevels(next AudioLevels) {
normalized := normalizeAudioLevels(next)
c.audioMu.Lock()
c.audioLevels = normalized
c.audioMu.Unlock()
c.applyAudioLevelsToMixer(normalized)
}
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
if payload == nil {
return nil
}
levels := c.getAudioLevels()
if payload.HornGain != nil {
levels.HornGain = clampAudioGain(*payload.HornGain)
}
if payload.TTSGain != nil {
levels.TTSGain = clampAudioGain(*payload.TTSGain)
}
if payload.ForwardGain != nil {
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
}
c.setAudioLevels(levels)
return nil
}
func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) {
c.applyMixerGain("HornMaster", levels.HornGain)
c.applyMixerGain("TTSMaster", levels.TTSGain)
c.applyMixerGain("ForwardMaster", levels.ForwardGain)
}
func (c *WSClient) applyMixerGain(control string, gain float64) {
normalized := clampAudioGain(gain)
if normalized <= 0 {
if err := c.trySetMixerControl(control, "0%"); err != nil {
c.log.Printf("audio-levels: amixer mute %s failed: %v", control, err)
}
return
}
// Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf.
db := 20.0 * math.Log10(normalized)
if db > 12.0 {
db = 12.0
}
if db < -60.0 {
db = -60.0
}
// amixer treats a leading "-" value as an option; set via percent to avoid getopt ambiguity.
percent := int(math.Round((db + 60.0) / 72.0 * 100.0))
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
percentArg := fmt.Sprintf("%d%%", percent)
if err := c.trySetMixerControl(control, percentArg); err != nil {
c.log.Printf("audio-levels: amixer set %s=%s failed: %v", control, percentArg, err)
}
}
func (c *WSClient) trySetMixerControl(control, value string) error {
// Prefer the active ALSA default route; fall back to card index for compatibility.
candidates := [][]string{
{"-q", "-D", "default", "sset", control, value},
{"-q", "-c", "0", "sset", control, value},
}
var lastErr error
for _, args := range candidates {
out, err := exec.Command("amixer", args...).CombinedOutput()
if err == nil {
return nil
}
lastErr = fmt.Errorf("%w (%s)", err, string(out))
}
return lastErr
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
const ( const (
autoChargeTimeout = 5 * time.Second autoChargeTimeout = 10 * time.Second
autoChargeCooldown = 0 * time.Minute autoChargeCooldown = 0 * time.Minute
sourceHomeBase = 1 << 1 sourceHomeBase = 1 << 1
) )
+4 -92
View File
@@ -7,7 +7,6 @@ import (
"log" "log"
"math" "math"
"sync" "sync"
"time"
rpio "github.com/stianeikeland/go-rpio/v4" rpio "github.com/stianeikeland/go-rpio/v4"
) )
@@ -18,17 +17,9 @@ type CameraServo struct {
pin rpio.Pin pin rpio.Pin
mu sync.Mutex mu sync.Mutex
currentAngle float64 currentAngle float64
desiredAngle float64
lastMove time.Time
moving bool
stopCh chan struct{}
closed bool closed bool
} }
const maxServoDegPerSec = 60.0
const servoStepInterval = 20 * time.Millisecond
const servoAngleEpsilon = 0.01
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) { func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
if !cfg.Enabled { if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled") return nil, fmt.Errorf("camera servo disabled")
@@ -46,7 +37,6 @@ func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, er
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
pin: pin, pin: pin,
stopCh: make(chan struct{}),
} }
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil { if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
rpio.Close() rpio.Close()
@@ -64,10 +54,6 @@ func (s *CameraServo) Close() {
} }
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle)) s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
rpio.Close() rpio.Close()
if s.stopCh != nil {
close(s.stopCh)
s.stopCh = nil
}
s.closed = true s.closed = true
} }
@@ -82,13 +68,8 @@ func (s *CameraServo) setAngleLocked(angle float64) error {
return fmt.Errorf("servo closed") return fmt.Errorf("servo closed")
} }
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle) clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
s.desiredAngle = clamped s.applyPulseLocked(s.angleToPulse(clamped))
limited := s.rateLimitAngleLocked(clamped) s.currentAngle = clamped
s.applyPulseLocked(s.angleToPulse(limited))
s.currentAngle = limited
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
s.startMoveLoopLocked()
}
return nil return nil
} }
@@ -114,15 +95,8 @@ func (s *CameraServo) SetPulseWidth(micros int) error {
if micros <= 0 { if micros <= 0 {
return fmt.Errorf("pulse width must be > 0") return fmt.Errorf("pulse width must be > 0")
} }
clampedPulse := clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs) s.applyPulseLocked(micros)
targetAngle := s.pulseToAngle(clampedPulse) s.currentAngle = s.pulseToAngle(micros)
s.desiredAngle = targetAngle
limited := s.rateLimitAngleLocked(targetAngle)
s.applyPulseLocked(s.angleToPulse(limited))
s.currentAngle = limited
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
s.startMoveLoopLocked()
}
return nil return nil
} }
@@ -137,68 +111,6 @@ func (s *CameraServo) applyPulseLocked(micros int) {
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen)) s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
} }
func (s *CameraServo) startMoveLoopLocked() {
if s.moving || s.stopCh == nil {
return
}
s.moving = true
go func() {
ticker := time.NewTicker(servoStepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.mu.Lock()
if s.closed {
s.moving = false
s.mu.Unlock()
return
}
if math.Abs(s.currentAngle-s.desiredAngle) <= servoAngleEpsilon {
s.moving = false
s.mu.Unlock()
return
}
limited := s.rateLimitAngleLocked(s.desiredAngle)
s.applyPulseLocked(s.angleToPulse(limited))
s.currentAngle = limited
s.mu.Unlock()
case <-s.stopCh:
return
}
}
}()
}
func (s *CameraServo) rateLimitAngleLocked(target float64) float64 {
now := time.Now()
if s.lastMove.IsZero() {
s.lastMove = now
}
elapsed := now.Sub(s.lastMove).Seconds()
if elapsed <= 0 {
s.lastMove = now
return s.currentAngle
}
maxElapsed := servoStepInterval.Seconds()
if elapsed > maxElapsed {
elapsed = maxElapsed
}
maxDelta := maxServoDegPerSec * elapsed
delta := target - s.currentAngle
if math.Abs(delta) <= maxDelta {
s.lastMove = now
return target
}
if delta > 0 {
target = s.currentAngle + maxDelta
} else {
target = s.currentAngle - maxDelta
}
s.lastMove = now
return target
}
func (s *CameraServo) angleToPulse(angle float64) int { func (s *CameraServo) angleToPulse(angle float64) int {
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
if totalRange == 0 { if totalRange == 0 {
-187
View File
@@ -1,187 +0,0 @@
package main
import (
"bufio"
"encoding/binary"
"flag"
"fmt"
"log"
"math"
"os/exec"
"strconv"
"strings"
"time"
)
const twoPi = 2 * math.Pi
func main() {
var (
device = flag.String("device", "", "ALSA device (empty = default)")
rate = flag.Int("rate", 48000, "Sample rate in Hz")
channels = flag.Int("channels", 1, "Number of audio channels")
duration = flag.Duration("duration", 2*time.Second, "Total horn duration")
freqsRaw = flag.String("freqs", "440,550,660", "Comma-separated frequencies in Hz")
volume = flag.Float64("volume", 0.25, "Output volume 0.0-1.0")
attack = flag.Duration("attack", 20*time.Millisecond, "Attack time")
release = flag.Duration("release", 60*time.Millisecond, "Release time")
)
flag.Parse()
if *rate <= 0 {
log.Fatalf("rate must be > 0 (got %d)", *rate)
}
if *channels <= 0 {
log.Fatalf("channels must be > 0 (got %d)", *channels)
}
if *duration <= 0 {
log.Fatalf("duration must be > 0 (got %s)", *duration)
}
if *volume <= 0 || *volume > 1.0 {
log.Fatalf("volume must be within (0,1] (got %.3f)", *volume)
}
if *attack < 0 || *release < 0 {
log.Fatalf("attack/release must be >= 0")
}
freqs, err := parseFreqs(*freqsRaw)
if err != nil {
log.Fatalf("parse freqs: %v", err)
}
if len(freqs) == 0 {
log.Fatal("no frequencies provided")
}
if *attack+*release > *duration {
log.Fatalf("attack+release must be <= duration (%s + %s > %s)", *attack, *release, *duration)
}
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", *channels), "-r", fmt.Sprintf("%d", *rate), "-t", "raw"}
if *device != "" {
args = append(args, "-D", *device)
}
cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
log.Fatalf("aplay stdin: %v", err)
}
if err := cmd.Start(); err != nil {
log.Fatalf("start aplay: %v", err)
}
writer := bufio.NewWriterSize(stdin, 32*1024)
if err := synthChord(writer, freqs, *rate, *channels, *duration, *volume, *attack, *release); err != nil {
_ = stdin.Close()
_ = cmd.Wait()
log.Fatalf("synth: %v", err)
}
if err := writer.Flush(); err != nil {
_ = stdin.Close()
_ = cmd.Wait()
log.Fatalf("flush: %v", err)
}
if err := stdin.Close(); err != nil {
_ = cmd.Wait()
log.Fatalf("close stdin: %v", err)
}
if err := cmd.Wait(); err != nil {
log.Fatalf("aplay failed: %v", err)
}
log.Print("Horn verification complete")
}
func parseFreqs(raw string) ([]float64, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
parts := strings.Split(trimmed, ",")
freqs := make([]float64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseFloat(part, 64)
if err != nil {
return nil, fmt.Errorf("invalid freq %q", part)
}
if value <= 0 {
return nil, fmt.Errorf("freq must be > 0 (got %.3f)", value)
}
freqs = append(freqs, value)
}
return freqs, nil
}
func synthChord(writer *bufio.Writer, freqs []float64, rate, channels int, duration time.Duration, volume float64, attack, release time.Duration) error {
totalFrames := int(float64(rate) * duration.Seconds())
if totalFrames <= 0 {
return fmt.Errorf("duration too short")
}
phase := make([]float64, len(freqs))
increment := make([]float64, len(freqs))
for i, f := range freqs {
increment[i] = twoPi * f / float64(rate)
}
attackFrames := int(float64(rate) * attack.Seconds())
releaseFrames := int(float64(rate) * release.Seconds())
steadyFrames := totalFrames - attackFrames - releaseFrames
framesPerChunk := 512
buf := make([]byte, framesPerChunk*channels*2)
sampleIndex := 0
scale := volume / float64(len(freqs))
for framesLeft := totalFrames; framesLeft > 0; {
framesNow := framesPerChunk
if framesLeft < framesNow {
framesNow = framesLeft
}
for i := 0; i < framesNow; i++ {
env := envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames)
sample := 0.0
for j := range freqs {
sample += sawFromPhase(phase[j])
phase[j] += increment[j]
if phase[j] > twoPi {
phase[j] -= twoPi
}
}
sample *= scale * env
if sample > 1.0 {
sample = 1.0
} else if sample < -1.0 {
sample = -1.0
}
intSample := int16(sample * math.MaxInt16)
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
}
sampleIndex++
}
if _, err := writer.Write(buf[:framesNow*channels*2]); err != nil {
return err
}
framesLeft -= framesNow
}
return nil
}
func envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames int) float64 {
if attackFrames > 0 && sampleIndex < attackFrames {
return float64(sampleIndex) / float64(attackFrames)
}
if releaseFrames > 0 && sampleIndex >= attackFrames+steadyFrames {
relIndex := sampleIndex - (attackFrames + steadyFrames)
return float64(releaseFrames-relIndex) / float64(releaseFrames)
}
return 1.0
}
func sawFromPhase(phase float64) float64 {
return 2.0*(phase/twoPi) - 1.0
}
-22
View File
@@ -3,15 +3,12 @@ package roverd
type helloMessage struct { type helloMessage struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"` Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"` MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"` CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"` Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
NightVision NightVisionConfig `json:"nightVision"` NightVision NightVisionConfig `json:"nightVision"`
Private PrivateConfig `json:"private"`
} }
type sensorMessage struct { type sensorMessage struct {
@@ -30,11 +27,8 @@ type inboundMessage struct {
Media *mediaCommand `json:"media,omitempty"` Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"` Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"` TTS *ttsPayload `json:"tts,omitempty"`
Horn *hornPayload `json:"horn,omitempty"`
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"` NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"` Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
} }
type driveDirectPayload struct { type driveDirectPayload struct {
@@ -70,18 +64,6 @@ type ttsPayload struct {
Speak bool `json:"speak,omitempty"` Speak bool `json:"speak,omitempty"`
} }
type hornPayload struct {
Action string `json:"action"`
Waveform string `json:"waveform,omitempty"`
Freqs []float64 `json:"freqs,omitempty"`
}
type audioLevelsPayload struct {
HornGain *float64 `json:"hornGain,omitempty"`
TTSGain *float64 `json:"ttsGain,omitempty"`
ForwardGain *float64 `json:"forwardGain,omitempty"`
}
type nightVisionPayload struct { type nightVisionPayload struct {
Action string `json:"action"` Action string `json:"action"`
} }
@@ -97,10 +79,6 @@ type songNote struct {
Duration int `json:"duration"` Duration int `json:"duration"`
} }
type rebootPayload struct {
DelayMs int `json:"delayMs,omitempty"`
}
type ackMessage struct { type ackMessage struct {
Type string `json:"type"` Type string `json:"type"`
ID string `json:"id"` ID string `json:"id"`
+11 -156
View File
@@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"regexp"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -58,7 +56,6 @@ type BatteryConfig struct {
type AudioConfig struct { type AudioConfig struct {
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"` CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"` CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,omitempty"`
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"` SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
Channels int `yaml:"channels" json:"channels,omitempty"` Channels int `yaml:"channels" json:"channels,omitempty"`
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"` Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
@@ -68,21 +65,9 @@ type AudioConfig struct {
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"` DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
} }
type HornConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Volume float64 `yaml:"volume" json:"-"`
SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"`
Device string `yaml:"device" json:"-"`
SineGain float64 `yaml:"sineGain" json:"-"`
SawGain float64 `yaml:"sawGain" json:"-"`
MaxDuration Duration `yaml:"maxDuration" json:"-"`
}
type MediaConfig struct { type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"` PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"` AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
PublishPort int `yaml:"publishPort" json:"-"` PublishPort int `yaml:"publishPort" json:"-"`
Manage bool `yaml:"manage"` Manage bool `yaml:"manage"`
ManageAudio bool `yaml:"manageAudio"` ManageAudio bool `yaml:"manageAudio"`
@@ -118,45 +103,17 @@ type NightVisionConfig struct {
InitialOn bool `yaml:"initialOn" json:"initialOn"` InitialOn bool `yaml:"initialOn" json:"initialOn"`
} }
type AutoSideBrushConfig struct {
Enabled bool `yaml:"enabled"`
Speed int `yaml:"speed"`
}
type PrivateConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
}
type PrivateSafetyConfig struct {
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
}
type Config struct { type Config struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Color string `yaml:"color" json:"color,omitempty"` ServerURL string `yaml:"serverUrl"`
ServerURL string `yaml:"serverUrl"` Serial SerialConfig `yaml:"serial"`
Serial SerialConfig `yaml:"serial"` BRC BRCConfig `yaml:"brc"`
BRC BRCConfig `yaml:"brc"` Battery BatteryConfig `yaml:"battery"`
Battery BatteryConfig `yaml:"battery"` MaxWheelMMs int `yaml:"maxWheelSpeed"`
MaxWheelMMs int `yaml:"maxWheelSpeed"` Media MediaConfig `yaml:"media"`
Media MediaConfig `yaml:"media"` CameraServo CameraServoConfig `yaml:"cameraServo"`
CameraServo CameraServoConfig `yaml:"cameraServo"` Audio AudioConfig `yaml:"audio"`
Audio AudioConfig `yaml:"audio"` NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
Horn HornConfig `yaml:"horn"`
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
Private PrivateConfig `yaml:"private" json:"private"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {
@@ -195,7 +152,6 @@ func LoadConfig(path string) (*Config, error) {
Audio: AudioConfig{ Audio: AudioConfig{
CaptureEnabled: false, CaptureEnabled: false,
CaptureDevice: "rovermic", CaptureDevice: "rovermic",
PlaybackDevice: "forward",
SampleRate: 48000, SampleRate: 48000,
Channels: 2, Channels: 2,
Bitrate: 24000, Bitrate: 24000,
@@ -204,41 +160,12 @@ func LoadConfig(path string) (*Config, error) {
DefaultVoice: "rms", DefaultVoice: "rms",
DefaultPitch: 50, DefaultPitch: 50,
}, },
Horn: HornConfig{
Enabled: false,
Volume: 0.25,
SampleRate: 48000,
Channels: 1,
SineGain: 1.0,
SawGain: 0.7,
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
},
NightVision: NightVisionConfig{ NightVision: NightVisionConfig{
Enabled: true, Enabled: true,
GPIOPin: 22, GPIOPin: 22,
GPIOChip: "gpiochip0", GPIOChip: "gpiochip0",
InitialOn: true, InitialOn: true,
}, },
AutoSideBrush: AutoSideBrushConfig{
Enabled: true,
Speed: 20,
},
Private: PrivateConfig{
Enabled: false,
Safety: PrivateSafetyConfig{
SpeedLimitEnabled: false,
SpeedLimitMaxWheelMMs: 250,
HardOvercurrentEnabled: false,
OvercurrentStopMs: 300,
HardBumpEnabled: false,
BumpBackoffSpeed: 250,
BumpBackoffMs: 350,
CliffEnabled: false,
CliffBackoffSpeed: 250,
CliffBackoffMs: 500,
TriggerCooldownMs: 800,
},
},
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
@@ -246,11 +173,6 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Name == "" { if cfg.Name == "" {
return nil, errors.New("missing name") return nil, errors.New("missing name")
} }
normalizedColor, err := normalizeHexColor(cfg.Color)
if err != nil {
return nil, err
}
cfg.Color = normalizedColor
if cfg.ServerURL == "" { if cfg.ServerURL == "" {
return nil, errors.New("missing serverUrl") return nil, errors.New("missing serverUrl")
} }
@@ -292,13 +214,6 @@ func LoadConfig(path string) (*Config, error) {
} }
cfg.Media.AudioPublishURL = derived cfg.Media.AudioPublishURL = derived
} }
if cfg.Media.AudioForwardURL == "" {
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
}
cfg.Media.AudioForwardURL = derived
}
if err := validateServoConfig(&cfg.CameraServo); err != nil { if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err) return nil, fmt.Errorf("cameraServo: %w", err)
} }
@@ -306,8 +221,6 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("nightVision: %w", err) return nil, fmt.Errorf("nightVision: %w", err)
} }
validateAudioConfig(&cfg.Audio) validateAudioConfig(&cfg.Audio)
validateHornConfig(&cfg.Horn)
validateAutoSideBrushConfig(&cfg.AutoSideBrush)
return &cfg, nil return &cfg, nil
} }
@@ -358,9 +271,6 @@ func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" { if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "hw:0,0" cfg.CaptureDevice = "hw:0,0"
} }
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
cfg.PlaybackDevice = "forward"
}
if cfg.SampleRate <= 0 { if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000 cfg.SampleRate = 48000
} }
@@ -381,30 +291,6 @@ func validateAudioConfig(cfg *AudioConfig) {
} }
} }
func validateHornConfig(cfg *HornConfig) {
if cfg.Volume <= 0 {
cfg.Volume = 0.25
}
if cfg.Volume > 1 {
cfg.Volume = 1
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 1
}
if cfg.SineGain <= 0 {
cfg.SineGain = 1.0
}
if cfg.SawGain <= 0 {
cfg.SawGain = 0.7
}
if cfg.MaxDuration.Duration <= 0 {
cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond}
}
}
func validateNightVisionConfig(cfg *NightVisionConfig) error { func validateNightVisionConfig(cfg *NightVisionConfig) error {
if !cfg.Enabled { if !cfg.Enabled {
return nil return nil
@@ -418,28 +304,10 @@ func validateNightVisionConfig(cfg *NightVisionConfig) error {
return nil return nil
} }
func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
if cfg.Speed == 0 {
return
}
cfg.Speed = clampInt(cfg.Speed, -127, 127)
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) { func derivePublishURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "publish")
}
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "request")
}
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
if streamName == "" { if streamName == "" {
return "", errors.New("missing stream name for publishUrl") return "", errors.New("missing stream name for publishUrl")
} }
if mode == "" {
mode = "publish"
}
parsed, err := url.Parse(serverURL) parsed, err := url.Parse(serverURL)
if err != nil { if err != nil {
return "", err return "", err
@@ -452,18 +320,5 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
port = 9000 port = 9000
} }
escaped := url.PathEscape(streamName) escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped), nil
}
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
func normalizeHexColor(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if !hexColorRe.MatchString(trimmed) {
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
}
return strings.ToUpper(trimmed), nil
} }
-270
View File
@@ -1,270 +0,0 @@
package roverd
import (
"bufio"
"encoding/binary"
"fmt"
"log"
"math"
"os/exec"
"strings"
"sync"
"time"
)
const (
hornAttack = 20 * time.Millisecond
hornRelease = 60 * time.Millisecond
)
type HornSynth struct {
cfg HornConfig
log *log.Logger
gain float64
mu sync.Mutex
stop chan struct{}
active bool
proc *exec.Cmd
}
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
return &HornSynth{
cfg: cfg,
log: logger,
gain: 1.0,
}
}
func (h *HornSynth) SetGlobalGain(gain float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.gain = clampAudioGain(gain)
}
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
if payload == nil {
return fmt.Errorf("horn payload required")
}
action := strings.ToLower(strings.TrimSpace(payload.Action))
switch action {
case "start", "on", "honk":
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
if waveform != "sine" && waveform != "saw" {
waveform = "saw"
}
freqs := sanitizeHornFreqs(payload.Freqs)
if len(freqs) == 0 {
h.Stop()
return nil
}
return h.Start(waveform, freqs)
case "stop", "off":
h.Stop()
return nil
default:
return fmt.Errorf("unsupported horn action: %s", payload.Action)
}
}
func (h *HornSynth) Start(waveform string, freqs []float64) error {
h.mu.Lock()
if h.active {
h.mu.Unlock()
return nil
}
stop := make(chan struct{})
h.stop = stop
h.active = true
h.mu.Unlock()
go h.run(waveform, freqs, stop)
return nil
}
func (h *HornSynth) Stop() {
h.mu.Lock()
if !h.active {
h.mu.Unlock()
return
}
stop := h.stop
proc := h.proc
h.stop = nil
h.proc = nil
h.active = false
h.mu.Unlock()
if stop != nil {
close(stop)
}
if proc != nil && proc.Process != nil {
_ = proc.Process.Kill()
}
}
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) {
rate := h.cfg.SampleRate
if rate <= 0 {
rate = 48000
}
channels := h.cfg.Channels
if channels <= 0 {
channels = 1
}
volume := h.cfg.Volume
if volume <= 0 {
volume = 0.25
}
if volume > 1 {
volume = 1
}
h.mu.Lock()
gain := h.gain
h.mu.Unlock()
volume *= gain
device := strings.TrimSpace(h.cfg.Device)
if device == "" {
device = "horn"
}
args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
cmd := exec.Command("aplay", args...)
stdin, err := cmd.StdinPipe()
if err != nil {
h.log.Printf("horn: aplay stdin failed: %v", err)
return
}
if err := cmd.Start(); err != nil {
h.log.Printf("horn: aplay start failed: %v", err)
_ = stdin.Close()
return
}
h.mu.Lock()
if h.active {
h.proc = cmd
}
h.mu.Unlock()
writer := bufio.NewWriterSize(stdin, 32*1024)
maxFrames := 0
if h.cfg.MaxDuration.Duration > 0 {
maxFrames = int(float64(rate) * h.cfg.MaxDuration.Duration.Seconds())
}
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, maxFrames, stop); err != nil {
h.log.Printf("horn: synth failed: %v", err)
}
_ = writer.Flush()
_ = stdin.Close()
if err := cmd.Wait(); err != nil {
h.log.Printf("horn: aplay exit: %v", err)
}
h.mu.Lock()
if h.proc == cmd {
h.proc = nil
}
h.mu.Unlock()
}
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, maxFrames int, stop <-chan struct{}) error {
phase := make([]float64, len(freqs))
increment := make([]float64, len(freqs))
for i, f := range freqs {
increment[i] = 2 * math.Pi * f / float64(rate)
}
attackFrames := int(float64(rate) * hornAttack.Seconds())
releaseFrames := int(float64(rate) * hornRelease.Seconds())
framesPerChunk := 512
buf := make([]byte, framesPerChunk*channels*2)
scale := volume / float64(len(freqs))
if waveform == "sine" {
scale *= h.cfg.SineGain
} else {
scale *= h.cfg.SawGain
}
stopRequested := false
releaseStart := -1
sampleIndex := 0
for {
if !stopRequested {
select {
case <-stop:
stopRequested = true
releaseStart = sampleIndex
default:
}
}
for i := 0; i < framesPerChunk; i++ {
if maxFrames > 0 && sampleIndex >= maxFrames && !stopRequested {
stopRequested = true
releaseStart = sampleIndex
}
env := 1.0
if attackFrames > 0 && sampleIndex < attackFrames {
env = float64(sampleIndex) / float64(attackFrames)
} else if stopRequested && releaseFrames > 0 {
relIndex := sampleIndex - releaseStart
if relIndex >= releaseFrames {
return nil
}
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
} else if stopRequested {
return nil
}
sample := 0.0
for j := range freqs {
switch waveform {
case "sine":
sample += math.Sin(phase[j])
default:
sample += sawFromPhase(phase[j])
}
phase[j] += increment[j]
if phase[j] > 2*math.Pi {
phase[j] -= 2 * math.Pi
}
}
sample *= scale * env
if sample > 1.0 {
sample = 1.0
} else if sample < -1.0 {
sample = -1.0
}
intSample := int16(sample * math.MaxInt16)
offset := i * channels * 2
for ch := 0; ch < channels; ch++ {
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
}
sampleIndex++
}
if _, err := writer.Write(buf); err != nil {
return err
}
}
}
func sanitizeHornFreqs(freqs []float64) []float64 {
if len(freqs) == 0 {
return nil
}
out := make([]float64, 0, 4)
for _, f := range freqs {
if len(out) >= 4 {
break
}
if f <= 0 {
continue
}
out = append(out, f)
}
return out
}
func sawFromPhase(phase float64) float64 {
return 2.0*(phase/(2*math.Pi)) - 1.0
}
-8
View File
@@ -27,9 +27,6 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if audio.CaptureEnabled && media.AudioPublishURL != "" { if audio.CaptureEnabled && media.AudioPublishURL != "" {
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL) fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
} }
if media.AudioForwardURL != "" {
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
}
if media.VideoWidth > 0 { if media.VideoWidth > 0 {
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth) fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
} }
@@ -52,11 +49,6 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
} }
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled)) fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice) fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
playbackDevice := audio.PlaybackDevice
if playbackDevice == "" {
playbackDevice = "forward"
}
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate) fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels) fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil { if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
-6
View File
@@ -81,12 +81,6 @@ func (n *NightVisionLight) HandleAction(action string) error {
} }
} }
func (n *NightVisionLight) NightVisionOn() bool {
n.mu.Lock()
defer n.mu.Unlock()
return !n.on
}
func (n *NightVisionLight) setLocked(on bool) error { func (n *NightVisionLight) setLocked(on bool) error {
if err := n.line.SetValue(boolToGPIO(on)); err != nil { if err := n.line.SetValue(boolToGPIO(on)); err != nil {
return err return err
-4
View File
@@ -18,7 +18,3 @@ func (n *NightVisionLight) Close() {}
func (n *NightVisionLight) HandleAction(action string) error { func (n *NightVisionLight) HandleAction(action string) error {
return fmt.Errorf("night vision not supported in dummy build") return fmt.Errorf("night vision not supported in dummy build")
} }
func (n *NightVisionLight) NightVisionOn() bool {
return false
}
BIN
View File
Binary file not shown.
-28
View File
@@ -1,6 +1,5 @@
# Sample configuration for roverd # Sample configuration for roverd
name: roomba-alpha name: roomba-alpha
color: "#4DB6AC"
serverUrl: ws://control-server.local:8080/rover serverUrl: ws://control-server.local:8080/rover
serial: serial:
device: /dev/ttyAMA0 device: /dev/ttyAMA0
@@ -17,7 +16,6 @@ battery:
maxWheelSpeed: 350 maxWheelSpeed: 350
media: media:
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316 publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
publishPort: 9000 publishPort: 9000
videoBitrate: 2000000 videoBitrate: 2000000
manage: true manage: true
@@ -40,7 +38,6 @@ cameraServo:
audio: audio:
captureEnabled: false captureEnabled: false
captureDevice: hw:0,0 captureDevice: hw:0,0
playbackDevice: forward
sampleRate: 48000 sampleRate: 48000
channels: 2 channels: 2
bitrate: 24000 bitrate: 24000
@@ -48,33 +45,8 @@ audio:
defaultEngine: flite defaultEngine: flite
defaultVoice: rms defaultVoice: rms
defaultPitch: 50 defaultPitch: 50
horn:
enabled: false
volume: 0.25
sampleRate: 48000
channels: 1
sineGain: 1.0
sawGain: 0.7
maxDuration: 1.2s
nightVision: nightVision:
enabled: true enabled: true
gpioPin: 22 gpioPin: 22
gpioChip: gpiochip0 gpioChip: gpiochip0
initialOn: true initialOn: true
autoSideBrush:
enabled: true
speed: 20
private:
enabled: false
safety:
speedLimitEnabled: false
speedLimitMaxWheelSpeed: 250
hardOvercurrentEnabled: false
overcurrentStopMs: 300
hardBumpEnabled: false
bumpBackoffSpeed: 250
bumpBackoffMs: 350
cliffEnabled: false
cliffBackoffSpeed: 250
cliffBackoffMs: 500
triggerCooldownMs: 800
-17
View File
@@ -31,20 +31,3 @@ cameraServo:
homeAngle: 0 homeAngle: 0
nudgeDegrees: 2 nudgeDegrees: 2
allowRawPulse: false allowRawPulse: false
autoSideBrush:
enabled: true
speed: 20
private:
enabled: false
safety:
speedLimitEnabled: false
speedLimitMaxWheelSpeed: 250
hardOvercurrentEnabled: false
overcurrentStopMs: 300
hardBumpEnabled: false
bumpBackoffSpeed: 250
bumpBackoffMs: 350
cliffEnabled: false
cliffBackoffSpeed: 250
cliffBackoffMs: 500
triggerCooldownMs: 800
+5 -187
View File
@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"os/exec"
"sync" "sync"
"time" "time"
@@ -20,22 +19,15 @@ type WSClient struct {
events chan RoverEvent events chan RoverEvent
media *MediaSupervisor media *MediaSupervisor
servo *CameraServo servo *CameraServo
horn *HornSynth
nightVision *NightVisionLight nightVision *NightVisionLight
log *log.Logger log *log.Logger
recoverMu sync.Mutex recoverMu sync.Mutex
recovering bool recovering bool
ttsQueue chan *ttsPayload ttsQueue chan *ttsPayload
lastAux motorPWMPayload
autoSideOn bool
connMu sync.Mutex connMu sync.Mutex
connected bool connected bool
disconnectT *time.Timer disconnectT *time.Timer
rebootT *time.Timer
seekIssued bool seekIssued bool
rebootIssued bool
audioLevels AudioLevels
audioMu sync.RWMutex
} }
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient { func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
@@ -43,35 +35,21 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
if cfg.Audio.TTSEnabled { if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2) ttsQueue = make(chan *ttsPayload, 2)
} }
var horn *HornSynth return &WSClient{
if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger)
}
client := &WSClient{
cfg: cfg, cfg: cfg,
adapter: adapter, adapter: adapter,
sensorFrames: frames, sensorFrames: frames,
events: events, events: events,
media: media, media: media,
servo: servo, servo: servo,
horn: horn,
nightVision: nightVision, nightVision: nightVision,
log: logger, log: logger,
ttsQueue: ttsQueue, ttsQueue: ttsQueue,
audioLevels: AudioLevels{
HornGain: 1.0,
TTSGain: 1.0,
ForwardGain: 1.0,
},
} }
client.applyAudioLevelsToMixer(client.audioLevels)
return client
} }
func (c *WSClient) Run(ctx context.Context) error { func (c *WSClient) Run(ctx context.Context) error {
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
conn, _, err := websocket.Dial(dialCtx, c.cfg.ServerURL, nil)
cancel()
if err != nil { if err != nil {
c.markDisconnected() c.markDisconnected()
return err return err
@@ -87,16 +65,11 @@ func (c *WSClient) Run(ctx context.Context) error {
c.log.Printf("sensor stream init failed: %v", err) c.log.Printf("sensor stream init failed: %v", err)
} }
errCh := make(chan error, 2) errCh := make(chan error, 1)
c.startTTSWorker(ctx) c.startTTSWorker(ctx)
go func() { go func() {
errCh <- c.readLoop(ctx, conn) errCh <- c.readLoop(ctx, conn)
}() }()
go func() {
if err := c.keepalive(ctx, conn); err != nil {
errCh <- err
}
}()
go c.forwardSensors(ctx, conn) go c.forwardSensors(ctx, conn)
go c.forwardEvents(ctx, conn) go c.forwardEvents(ctx, conn)
@@ -113,15 +86,12 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
msg := helloMessage{ msg := helloMessage{
Type: "hello", Type: "hello",
Name: c.cfg.Name, Name: c.cfg.Name,
Color: c.cfg.Color,
Battery: c.cfg.Battery, Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs, MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media, Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo, CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio, Audio: c.cfg.Audio,
Horn: c.cfg.Horn,
NightVision: c.cfg.NightVision, NightVision: c.cfg.NightVision,
Private: c.cfg.Private,
} }
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin) c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg) return writeJSON(ctx, conn, msg)
@@ -165,17 +135,11 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
case msg.DriveDirect != nil: case msg.DriveDirect != nil:
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs) left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs) right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
if err := c.adapter.DriveDirect(left, right); err != nil { return c.adapter.DriveDirect(left, right)
return err
}
c.applyAutoSideBrush(left, right)
return nil
case msg.MotorPWM != nil: case msg.MotorPWM != nil:
main := clamp(msg.MotorPWM.Main, -127, 127) main := clamp(msg.MotorPWM.Main, -127, 127)
side := clamp(msg.MotorPWM.Side, -127, 127) side := clamp(msg.MotorPWM.Side, -127, 127)
vac := clamp(msg.MotorPWM.Vacuum, 0, 127) vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
c.lastAux = motorPWMPayload{Main: main, Side: side, Vacuum: vac}
c.autoSideOn = false
return c.adapter.MotorPWM(main, side, vac) return c.adapter.MotorPWM(main, side, vac)
case msg.SensorStream != nil: case msg.SensorStream != nil:
if msg.SensorStream.Enable { if msg.SensorStream.Enable {
@@ -206,121 +170,22 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleServoCommand(msg.Servo) return c.handleServoCommand(msg.Servo)
case msg.TTS != nil: case msg.TTS != nil:
return c.enqueueTTS(msg.TTS) return c.enqueueTTS(msg.TTS)
case msg.Horn != nil:
if c.horn == nil {
return fmt.Errorf("horn disabled")
}
return c.horn.HandlePayload(msg.Horn)
case msg.AudioLevels != nil:
return c.handleAudioLevels(msg.AudioLevels)
case msg.NightVision != nil: case msg.NightVision != nil:
if c.nightVision == nil { if c.nightVision == nil {
return fmt.Errorf("night vision disabled") return fmt.Errorf("night vision disabled")
} }
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil { return c.nightVision.HandleAction(msg.NightVision.Action)
return err
}
c.emitEvent("nightVision.state", map[string]any{
"nightVisionOn": c.nightVision.NightVisionOn(),
})
return nil
case msg.Song != nil: case msg.Song != nil:
slot := 0 slot := 0
if msg.Song.Slot != nil { if msg.Song.Slot != nil {
slot = clampInt(*msg.Song.Slot, 0, 4) slot = clampInt(*msg.Song.Slot, 0, 4)
} }
return c.adapter.PlaySong(slot, msg.Song.Notes) return c.adapter.PlaySong(slot, msg.Song.Notes)
case msg.Reboot != nil || msg.Type == "reboot":
return c.handleRebootCommand(msg.Reboot)
default: default:
return fmt.Errorf("unsupported command type: %s", msg.Type) return fmt.Errorf("unsupported command type: %s", msg.Type)
} }
} }
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
if err := c.adapter.DriveDirect(0, 0); err != nil {
return fmt.Errorf("stop drive before reboot: %w", err)
}
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
return fmt.Errorf("stop aux motors before reboot: %w", err)
}
if err := c.adapter.StartOI(); err != nil {
return fmt.Errorf("enter passive mode before reboot: %w", err)
}
delay := 300 * time.Millisecond
if payload != nil && payload.DelayMs > 0 {
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
}
c.connMu.Lock()
if c.rebootIssued {
c.connMu.Unlock()
return fmt.Errorf("reboot already pending")
}
c.rebootIssued = true
c.connMu.Unlock()
c.emitEvent("system.rebooting", map[string]any{
"source": "remoteCommand",
"delayMs": delay.Milliseconds(),
})
go func() {
time.Sleep(delay)
c.log.Printf("rebooting pi after remote reboot command")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
c.log.Printf("reboot command failed: %v", err)
}
}()
return nil
}
func (c *WSClient) applyAutoSideBrush(left, right int) {
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
if c.autoSideOn {
c.autoSideOn = false
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
c.log.Printf("auto side brush stop failed: %v", err)
}
}
return
}
moving := left != 0 || right != 0
if !moving {
if c.autoSideOn {
c.autoSideOn = false
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
c.log.Printf("auto side brush stop failed: %v", err)
}
}
return
}
if c.lastAux.Side != 0 {
c.autoSideOn = false
return
}
autoSpeed := clampInt(c.cfg.AutoSideBrush.Speed, -127, 127)
if autoSpeed == 0 {
c.autoSideOn = false
return
}
if c.autoSideOn {
return
}
if err := c.adapter.MotorPWM(c.lastAux.Main, autoSpeed, c.lastAux.Vacuum); err != nil {
c.log.Printf("auto side brush start failed: %v", err)
return
}
c.autoSideOn = true
}
func (c *WSClient) enqueueTTS(payload *ttsPayload) error { func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
if c.ttsQueue == nil { if c.ttsQueue == nil {
return fmt.Errorf("tts disabled") return fmt.Errorf("tts disabled")
@@ -487,43 +352,15 @@ func (c *WSClient) ensureSensorStream() error {
} }
const disconnectSeekDelay = time.Minute const disconnectSeekDelay = time.Minute
const disconnectRebootDelay = 6 * time.Minute
const dialTimeout = 10 * time.Second
const pingInterval = 15 * time.Second
const pingTimeout = 5 * time.Second
func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
err := conn.Ping(pingCtx)
cancel()
if err != nil {
return err
}
}
}
}
func (c *WSClient) markConnected() { func (c *WSClient) markConnected() {
c.connMu.Lock() c.connMu.Lock()
c.connected = true c.connected = true
c.seekIssued = false c.seekIssued = false
c.rebootIssued = false
if c.disconnectT != nil { if c.disconnectT != nil {
c.disconnectT.Stop() c.disconnectT.Stop()
c.disconnectT = nil c.disconnectT = nil
} }
if c.rebootT != nil {
c.rebootT.Stop()
c.rebootT = nil
}
c.connMu.Unlock() c.connMu.Unlock()
} }
@@ -535,9 +372,6 @@ func (c *WSClient) markDisconnected() {
if c.disconnectT == nil { if c.disconnectT == nil {
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout) c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
} }
if c.rebootT == nil {
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
}
c.connMu.Unlock() c.connMu.Unlock()
} }
@@ -557,22 +391,6 @@ func (c *WSClient) handleDisconnectTimeout() {
c.log.Printf("seek dock issued after websocket disconnect") c.log.Printf("seek dock issued after websocket disconnect")
} }
func (c *WSClient) handleRebootTimeout() {
c.connMu.Lock()
if c.connected || c.rebootIssued {
c.connMu.Unlock()
return
}
c.rebootIssued = true
c.connMu.Unlock()
c.log.Printf("rebooting pi after prolonged websocket disconnect")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
c.log.Printf("reboot command failed: %v", err)
}
}
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) { func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
c.recoverMu.Lock() c.recoverMu.Lock()
if c.recovering { if c.recovering {
-18
View File
@@ -1,18 +0,0 @@
[Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA)
After=network-online.target roverd.service
Wants=network-online.target
[Service]
Type=simple
User=roverd
Group=roverd
EnvironmentFile=/var/lib/roverd/video.env
ExecStart=/usr/local/bin/audio-forward-listener
KillMode=control-group
TimeoutStopSec=5
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
-14
View File
@@ -1,14 +0,0 @@
# main idea:
- stream audio from server to rovers
- users can either stream their mic from their browser
- users can also play audio files on the rover through the browser
- this is a VIP feature for verified users only
- gate in UI and in the server
## specifics
- only the current driver can play audio through a rover
- admins can enable / disable audio
- lockdown admins can adjust the volume for all rovers
- rovers are always listening for an audio stream from the server
- no transcoding allowed on-rover due to resources
- rovers are always local and cant be accessed from outside, no security is needed for audio streaming
+27
View File
@@ -0,0 +1,27 @@
# general idea
- the pi on each roomba will have a speaker and microphone
- alsa devices
- they are both the default audio device
- you will have to modify roverd, the pi install script, and the server to get this all to work
# adding stuff to installation script
- I am using the google voice AIY v1 kits for audio
- boot config stuff
- enable `dtoverlay=googlevoicehat-soundcard`
- disable `dtparam=audio=on`
- copy asound.conf in pi folder to /etc/asound.conf
# microphone
- add microphone to the SRT publish stream
# speaker
- user's chat messages will be TTS'ed through the speaker on the pi
- either flite with a way to choose the voice
- or espeak where you can choose the pitch
- on the web UI and in the chat API
- add new stuff to chat
- only shows up if you are on a rover
- only shows up if TTS is enabled on that rover
- people can choose between flite or espeak
- if they choose flite, they can choose the voice from the default flite voices (exclude awb and awb_time)
- if they choose espeak, they can choose the pitch. Have a dropdown with increments of 10 from 0 to 99
@@ -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
+27
View File
@@ -0,0 +1,27 @@
# general idea
- uses the rover's reported battery full, warn, and urgent values
- will apply to all rovers individually
- uses the rover locking system
- completely server side
- always use the battery warn value as 0% battery and the full value as 100%
## first: the server-side server-wide event bus
- global server event bus
- used for realtime alerts between modules
- includes a way to tell where the event is coming from
## what will the battery manager do?
- watch each rover's battery charge number (reported in sensors)
- if the number reaches warn, fire an event on the event bus
- the UI will show a warning to users, independently based on sensor data
- once the rover is docked and charging, lock it.
- when the battery is fully charged, unlock it.
## while we're at it...
- add a lock reason to the rover locking system
- add the following to the rover roster in the UI session state:
- battery full #
- battery warn #
- battery urgent #
- in the rover roster UI component, add a display for battery percentage and lock reason.
- make the rover's background red when locked
+9
View File
@@ -0,0 +1,9 @@
# general idea
- IR led on the front of each roomba
- connected to a pin on the pi
- made to be very directional
- each roomba has a different IR code
- use the omni reciever to get a shot
- keep score per user
- use the go alert system
- alert discord with a live score count
+35
View File
@@ -0,0 +1,35 @@
# general idea
- discord bot run by the server
- use discord.js
- don't use discord's slash commands
- listen for commands the old fashioned way
- only admins with IDs set in the server config can use commands
- use nice looking embeds with colors for everything but messages
# features
## admin server management commands
- admins can lock and unlock rovers from discord
- admins can change the server access control mode
## announcements
- the bot has an announcements channel assigned in the server config. It will also have an announcement role ID to optionally ping. it will announce:
- when a rover is locked / unlocked (no ping)
- when the server access mode is changed (ping)
- the bot will have an admin ping role and alert channel. in here will be:
- rover activity (no pings)
- docking
- charging
- stopping charging
- undocking
- rover alerts (ping)
- rover comes online
- rover goes offline
- rover is at warn battery
- rover is at urgent battery
## chat bridge
- bridge between the server chat and discord chat
- use chat events that are on the bus already
+15
View File
@@ -0,0 +1,15 @@
# general idea:
- control on / off switches and lights in home assistant from the web UI
- use this:
- https://www.npmjs.com/package/home-assistant-js-websocket/v/3.1.2
- remember to ignore updates that are of the same states, this library will give you a lot of those
- switches and lights are configured in the server's config file
- give each one a name (no description)
- auto detect a switch type or light type
- deliver list of lights to the UI through the session service
- show realtime on/off status of the switches / lights in the UI
- create a react component for the controls
- it will automatically create a control for each switch / light
## permissions:
- even if someone isnt assigned to a rover, they should be able to control the switches / lights
+13
View File
@@ -0,0 +1,13 @@
# general idea
- The idea of this feature is to toggle night vision on the camera by turning on an LED from GPIO on the pi which will me mounted in front of the camera's light sensor
- with this, the camera will disable night vision when the LED is ON, and enable night vision when the LED is OFF.
- this will only really involve pi and webui programming. the server passes commands straight through.
- do not track the state of night vision, it is not needed.
## pi side
- new GPIO on/off control on GPIO 17, 27, or 22. any of these will work.
## web UI
- a new keyboard shortcut to toggle night vision
- a new button in the mobile UI to toggle night vision
- put it above the mobile horizontal servo slider
+6
View File
@@ -0,0 +1,6 @@
## general idea
- new page
- designed for a very small screen
- not interactive at all
- a small summarized status of what's going on in the basement (where the rovers are)
- this small screen does not have space to show all info and all video for all rovers
+21
View File
@@ -0,0 +1,21 @@
# general idea
- a help system that pops up onscreen
- changes based on control layout
- mobile / desktop
- on desktop especially, keybinds will dynamically update, based on what is actually assigned
# UI layout specifics:
- remake the current help component from scratch
- leave it in the place where it is, so people can always look at the help
- match the styling of the rest of the page
- make sure the help is easy to edit
- add a pop-up which displays underneath any other popup layers, which will display the help component in a large format
- make this pop-up show every time UNLESS the user checks "dont show again"
- use the settings system to store this in the browser
# what will the help explain?
- how to use the nickname and chat
- how to start driving the rover
- rover controls
- this is the biggest thing that will change in different layouts
- how it works technically (just a placeholder for now)
+36
View File
@@ -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 MPEGTS: `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. If you expose playback under `/video/<id>` externally, let the reverse proxy rewrite that prefix back to `<id>` before forwarding to mediaMTX so the wildcard continues to match every rover.
- 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 servers 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 (≈250350ms 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.
+53
View File
@@ -0,0 +1,53 @@
# nicknames:
- users will be able to have and set nicknames
- a user's nickname will store in the browser using persistence.js
- the user's nickname should probably just be in socket.data.nickname
- on connection, the web UI will tell the server "this is my nickname"
- completely enforced by the web UI
- replace any place in the web UI that shows a socket ID with the nickname
- create a small react component which can set your nickname and save it
# user list in session data:
- add a list of users to the session data for the web UI
- contains for each user:
- socket ID
- nickname
- the rover that they are driving
- their role (user, admin, lockdown, spectator)
# user list:
- a list of users
- new react component
- uses user list with nicknames from session data
- show their nickname and whether or not they are an admin
# chat:
- server side chat system:
- don't store message history
- emit an event to the event bus for each message
- eventually will be forwarded to discord through a bot
- listen to chat message events on the bus
- eventually the discord bot will also send messages to the server chat
- profanity filter
- spam filter
- repeated words
- keymashing
- etc
- somehow link chat messages to rovers
- in the future rovers will have TTS onboard, and will speak chat messages only from the person driving the rover
- chat on the UI
- new react component
- show messages as they come in:
- time (just like 19:23), sender nickname, rover they are driving, message
- pressing enter on the keyboard will pause rover control, and focus the chat box.
- pressing enter again after typing will send the message
# layout and styling for new UI elements:
- use index.css styles to match the new stuff to the current UI
- just edit the desktop page for now
- layout:
- in the left column, a new row in between video and logs
- 50/50 split between:
- user list and nickname entry
- chat history and chat box
+58
View File
@@ -0,0 +1,58 @@
## multi roomba rover
a website where people can control multiple irobot create 2 robots in real time 100% responsively
with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera
a central nodejs control server will tell the raspberry pis what to do with the roomba
## the pi side (roomba side)
- pi's onboard UART is hooked up to the roomba's serial port
- another GPIO pin connected to the roomba's BRC pin
- pull it low for one second every minute to keep the roomba awake
- streams the rpi camera over webRTC with mediamtx
- streams roomba sensor group 100 to the server
- sensor streaming is required and important, but is allowed to falter sometimes
- listens for roomba commands from the server
- commands NEED to happen
- roomba control program needs to be simple, lightweight, and 100% responsive
## pi -> server communication
- stateless
- streaming based
- on connection, the pi will send the following info:
- rover's name
- motor enable / disable
- vacuum
- main brush
- side brush
- battery full number
- battery warning number
- battery urgent number
## nodejs server
- KISS
- decode the sensor data from each roomba
- can support multiple roombas connected from the ground up
- keep it simple, worry about getting the pi comms right.
- but the server DOES have to exist for testing
- IS the web server, hosts an entire static folder for the web UI
## server -> web UI communication
- socket.io
- don't do anything fancy with the socket.io setup
- it works fine out of the box, we will optimize it later
## the web UI
- KISS
- plain old html. no styling even. just bare minimum for testing
- what it needs to do:
- allow user to select the roomba from a list
- make the selected roomba drive with WASD
- have buttons to set the OI mode, and tell the roomba to dock
- show a plain list of the sensor data from the selected roomba
### general javascript programming guidelines (applies to the web UI too)
- everything ES6
- one entrypoint file in the web UI
- everything modular
- everything easy to read, understand, and work on
- comment where you think is best to describe whats going on
+13
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
# general idea
- the pi's microphone needs to be streamed to users
- as part of the rover's video feed
- the pi model is a zero 2 W
- it CANNOT handle transcoding the audio onboard, I have tried.
- the server is super powerful and can handle transcoding
- it is NOT acceptable to add more than 100ms of video latency from glass to glass
- it is NOT acceptable to break video streaming for pis that don't have microphones
### OLD, BAD, but working audio configuration that worked for the microphone
```
-f alsa -guess_layout_max 0 -thread_queue_size 2048 -ac 1 -ar 48000 -i plughw:0,0 \
-map 1:a:0 \
-c:a libopus -b:a 24000 -compression_level 0 -application voip -frame_duration 60 \
-ac:a 1 -ar:a 48000 -af "pan=1c|c0=c0,volume=20dB"
```
- the raspberry pi CANNOT HANDLE AUDIO ENCODING onboard. this is just to show you how to use the mic.
sudo systemctl restart roverd
#sudo systemctl restart audio-capture
sudo systemctl restart video-publisher
sudo cat /var/lib/roverd/video.env
-53
View File
@@ -1,53 +0,0 @@
# private rovers
## basic concept:
private rovers will be mostly just for lockdown admins to drive and use, but they can be temporarily unlocked manually by lockdown admins for use by verified users.
This means that locking / unlocking will act a little different than standard rovers.
- cannot be spectated by spectators, unless they are unlocked
- cannot be replayed, unless they are unlocked
- private status is defined in the roverd config
- needs to never leak through access to anyone while locked
- unlocking a private rover is a big deal for verified users (opening up a rover in the main living space for a special event)
- not included in LLM events system
- basically needs to be online but completely hidden when its not open
## locking / unlocking:
- private rovers start locked
- when locked, only lockdown admins can drive them
- when unlocked, only verified users (and lockdown admins of course) can drive them
- if left unlocked with no one online for 30 mins, the server will automatically lock them
- ## private rovers can be locked / unlocked by holding all 3 buttons on the top of the roomba for 3 seconds
- hold spot / clean / dock buttons for 3 seconds to toggle opened / closed on that private rover
- the server sends a TTS command to the rover to indicate when its toggled
## cliff rules / speed limit / overcurrent limit
### private rovers will be in a sensitive area, their physical capabilities will be optionally limited by the server, controllable by lockdown admins.
- optional toggleable limits:
- speed limit
- hard overcurrent limiting (stop motor for a bit the instant it overcurrents for maybe 0.3s)
- hard bump limits, stop and back up slightly on physical bumps of a certain short duration
- cliff drops. back up and pause when any cliff sensor triggers, use their binary outputs for this as they are tuned well from factory.
## UI specifics
- private rovers don't show in the spectator pages unless they are unlocked
- private rovers don't show in the list for normal users unless they are unlocked
- they will only show for lockdown admins
- when unlocked, they show for everyone
- with a different color in the rover list
## . . .
this will be kind of invasive, touching a lot of systems server-side, long story short:
- private rovers are set as private in the roverd config
- by default:
- locked to only lockdown admins
- cant be spectated by anyone
- any user who isnt a lockdown admin cannot know that it exists in any way at all
- not included by most automated systems like LLM integration, discord alerts, etc
- still included in safties like auto docking
- limitations dont apply because its lockdown admin only anyway
- chat messages from them dont get seen by anyone else at all, only sent to the rover for tts
- when opened up (can only be opened by lockdown admins):
- only verified users can drive them
- anyone can spectate them
- limits apply
- included in all automated systems just like a normal rover
+27
View File
@@ -0,0 +1,27 @@
## raspberry pi
<!-- - need to throttle sensor sending, maybe only send one in every 5 packets. -->
## 3d models for camera:
- https://www.thingiverse.com/thing:2873677
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
- https://www.thingiverse.com/thing:4514531
## todo
<!-- 1. battery manager -->
<!-- 2. pi-side sensor throttle (1/5th) -->
<!-- 3. convert rover roster to a reusable component, unbake it from telemetry and admin panels -->
<!-- 4. room cameras -->
4. fix gamepad input - more complex axis assignment? division, combined axes?
<!-- 5. home assistant controls -->
<!-- 6. nicknames -->
<!-- 7. discord bot -->
<!-- 8. turns mode display -->
<!-- 9. online user list -->
<!-- 10. chat -->
<!-- 9. discord invite button -->
<!-- 10. redo both mobile layouts -->
<!-- 11. redo spectator view (last) -->
<!-- 21. finally.. set the favicon and title -->
22. rover snapshots freezing and never coming back
23. rover snapshot -> video switching needs to be smoother, no black flash. connect and play before showing.
24. rover snapshots delayed (not just because of framerate)
+69
View File
@@ -0,0 +1,69 @@
# general idea
- a modular control system
- every piece of the UI that uses rover controls will go through this, including onscreen click buttons
- allows for dynamic control labels based on saved settings
- allow a place to assign OI command macros
- like the one that the drive button uses now
- allows realtime responsive control of the rover that you are driving
## some sort of system for the site to save settings per browser
- needs to be extensible
- future things will use it
- expose functions like saveSettings and loadSettings
- other parts of the UI will need to save and load settings using this function
- use cookies
## keyboard controls:
- remappable by new component in settings tab
- key mappings will save
- controls for the keyboard
- driving
- WASD blended for tank steering
- hold backslash to move faster
- hold right shift to move slower
- aux motors
- main brush
- hold O to move it forward at speed 127
- hold L to move it backward at speed -127
- side brush
- hold P to move it forward at speed 127
- hold ; to move it backward at speed -70
- vacuum motor
- hold [ to move it at speed 127
- hold ' to move it at speed 50
- ALL AUX MOTORS
- hold . to move at full speed forward (127)
- camera movement
- hold I to look up
- hold K to look down
- dock and drive hotkeys
- G for drive (use same 3 part macro as the drive button)
- H for dock (seek dock command)
## Mobile controls
- 2 different layouts, already implemented just needs improved.
### mobile landscape
- a video game style layout
- on the left, buttons that you hold to operate the aux motors
- in the middle, is the rover video component.
- on the right, an area for a floating joystick and above is a unified control to see and change the rover's mode (drive or dock)
### mobile portrait
- designed to be driven vertically with 2 hands
- mostly fine already
- rover video at the top
- then below it, is a section with the aux motor buttons on the left, and the floating joystick are on the right.
## Gamepad controls:
- use some sort of react thing that makes it easy to use the web gamepad stuff
- left joystick for movement, right joystick for moving camera up / down
- both fully analog
- right trigger for the main brush motor
- fully analog
- press right bumper to switch it to reverse
- left trigger for the side brush motor
- fully analog
- press left bumper to switch it to reverse
- hold the right face button to run the vacuum motor
- hold the lower face button to run all aux motors forward
- left and right on the Dpad switch between drive and dock modes
+10
View File
@@ -0,0 +1,10 @@
# general idea
- convert room cameras from webrtc h264 streams to 4fps jpegs sent over socket.io
## requirements
- make updated services for server and pi room cams
- no backwards compatability is needed
- room cameras are both 4:3, make sure they show as such in the UI
- remove anything related to the webRTC room cams
- send the jpegs efficiently (as binary)
- make sure the room cams are still authed as they are now
+14
View File
@@ -0,0 +1,14 @@
# general idea
- add support for multiple room cameras through the server to the UI
## what needs to happen on the server:
- add support for a path of room cameras on mediamtx
- /room/<camera_name>
- CANNOT interfere with rover cameras (/<rover_name>)
- needs to use the same auth system as the rover camera
- room cameras will be streamed to the server over SRT
## what needs to happen in the web UI
- users should be able to see all room cameras, even if not assigned to a rover
- automatically add a room camera player for each room camera
- make a component that shows all room cameras
+51
View File
@@ -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
+44
View File
@@ -0,0 +1,44 @@
# spectate system overhaul
- rip out and remake the entire page
- basically keep nothing from it
- right now the spectator page is a mess, and hasn't been updated in a while
- does not work properly, aside from the page being out of date
- the spectate page should use modules from the driver page
- there should be no such thing as a "spectate mode" on the driver page. If someone wants to spectate, they must go to the spectator page.
## rules of the spectator page
- spectators are not allowed to drive
- spectators are not allowed to chat
- spectators are not allowed to have nicknames
- spectators are not allowed to login as admin
- these are the permissions for spectators based on the sever's access control mode
- open
- spectators can spectate
- turns
- spectators can spectate
- admin
- spectators can spectate
- lockdown
- spectators can NOT spectate
## what should be on the spectator page?
- for each rover
- video component
- current driver
- sensor telemetry component
- logs
- room cameras
- online user list
## layout and styling of the spectator page
- use same styling style as the driver page
- feel free to add spectator role checks to hide the features that spectators can't use in certain components.
- mainly designed for a 4:3 monitor
- make use of the vertical space
- a row of columns, one for each rover at the top
- below the row of columns:
- 50/50 split
- user list
- chat
- room cameras
-22
View File
@@ -1,22 +0,0 @@
1. fix controls remapping [x]
2. trusted user system [x]
3. private rovers
4. custom webhook profile pictures for chat bridge in discord
5. home assistant switch that tells the server to force the lights on
6. color coding with colored names and tape [x]
7. audio forwarding [x]
- streaming from server to rovers [x]
- audio files first [x]
- then voice chat [x]
8. mobile controls column swapping (optional joystick on left) [x]
9. fix fullscreen on mobile so that you can re-enter it [x]
10. home assistant rover mute switch
# relative pipe dreams:
1. VPS video forwarding
1. get forwarding working with the VPS for in-queue users and spectators
2. bandwidth testing
3. maybe switch room cams back to real video, with audio?
2. overseer LED tesseract
3. RF based positional tracking / room map tab
4. chromecast monitor youtube search and speakers
+67
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
# general idea
- users can choose from preset, and create UI themes
- the default theme is the current in place theme
- themes will NOT change margins, borders, padding, spacing, etc.
- should be as simple as changing the global css stuff
- themes can change colors, fonts, border radius, borders, etc
## technical stuff
- save theme info in
## UI for themeing
- allow people to tune every theme element that there is to tune
- allow people to export themes as json files, and import them
-
+23
View File
@@ -0,0 +1,23 @@
# general idea
- a bunch of small UI adjustments
- always keep styling consistent to the rest of the page
## everywhere
- add an audio stream status to the rover video panel, only show it if the rover has the separate audio stream
- make the room camera panels save their stack setting per instance of the panel in the web UI's settings storage system
- remove the status bar below room cameras
- put it in the corner of the feed, like the other video panels
- make the styling of the room camera panels match everything else
- when the user list switches to turns, always show the plain user list, but below the turns info
## on the spectator page
- rover video panels
- no more telemetry bar below each rover video
- ovelay the tiny telemetry summary on the left side of the video, with the rest of the HUD elements
- including the battery bar, make it vertical on the right side of the video
- no more title bar above each rover video
- the rover's name is already on the HUD
- add current driver to the HUD
- sidebar
- shrink the logs panel
- add an actual rover list to the top of the sidebar
-45
View File
@@ -1,45 +0,0 @@
# user verification system
## main idea
- a relatively simple system to verify trusted users and allow them to use special features
- uses IP, a cookie user ID, and nickname to verify people
- expose internally similar to socket.isAdmin: socket.isVerified.
## on-connect system to send user info to the server
- a new system in the web UI (and server a little bit probably)
- ensures that the server gets all of your user info when you connect
- also ensures that the server can seamlessley remember who you are if you happen to lose connection and reconnect
- info contains:
- nickname (replace the current reconnect and nickname logic with this new system)
- cookie ID
- more stuff in the future probably
## cookie user ID
- an ID that the server assigns to a user
- saves as a setting in the settings persistence system in the user's browser
## how will the server verify people
- when a user connects and sends their user info:
- step 1: IP address OR cookie user ID
- if the user's IP or their cookie ID matches, continue to step 2
- step 2: nickname
- if the user's nickname matches to it's expected step 1, the user is now verified
- the user is now verified and added to a persistent database on the server
## how will verification requests work
- user goes through the process in the web UI
- the request is DMd to lockdown admins in discord
- each message can be reacted with a check or an x emoji by the lockdown admins to accept or deny a request
- no realtime UI feedback is needed for when a request is accepted or denied
## UI specifics
- a new VIP tab in the sidebar
- either shows a button to request verification, or shows the VIP controls
### verification process
- a button in the sidebar to request verification
- only shows if you aren't verified
- the actual process:
1. press the button
2. the page opens a new pop-up
3. it explains what verification is, how it works, and that your nickname is attached to your verification
4. prompts users to confirm their nickname, as if they change it their verification won't work
5. a final confirmation saying that their request has been sent
+28
View File
@@ -0,0 +1,28 @@
# Room camera snapshot service
Lightweight systemd service that serves a JPEG snapshot from any MJPEG-capable webcam (most USB webcams) at 4 fps. The server pulls `http://<host>:8080/snapshot.jpg` for room cams.
## Files
- `room-cam-snapshot.sh` ffmpeg + simple HTTP server wrapper
- `room-cam.service` systemd unit template
## Usage
1) Copy the service into place (adjust path/env as needed):
```bash
sudo cp roomcam-service/room-cam.service /etc/systemd/system/room-cam.service
sudo systemctl daemon-reload
sudo systemctl enable --now room-cam.service
```
2) Override defaults via `Environment=` in the unit or drop-ins:
- `DEVICE=/dev/video0`
- `RESOLUTION=640x480`
- `QUALITY=5` (ffmpeg MJPEG quality; lower is higher quality)
- `PORT=8080`
- `WORKDIR=/run/roomcam`
- `INPUT_FORMAT=mjpeg` (use `bayer_grbg8` for OV534/raw Bayer cams; aliases `GRBG`/`grbg` are accepted)
3) Point the server `roomCameras[].url` to `http://<host>:8080/snapshot.jpg`.
Notes:
- The camera runs at its native MJPEG frame rate; the server polls snapshots at ~4 fps, so no extra filtering is applied here.
To run outside systemd, just execute `./room-cam-snapshot.sh` with any overrides.
Binary file not shown.
-31
View File
@@ -8,29 +8,12 @@ admins:
discord_id: "0987654321" discord_id: "0987654321"
lockdown: true lockdown: true
timezone: "America/New_York" timezone: "America/New_York"
llmCommentary:
enabled: false
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
frequency: 120000
media: media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video # Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video" whepBaseUrl: "http://192.168.0.86:8889/video"
audioForward:
enabled: true
ffmpegBin: "ffmpeg"
streamSuffix: "-fwd"
maxUploadBytes: 8388608
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
homeAssistant: homeAssistant:
url: "http://homeassistant.local:8123" url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN" token: "REPLACE_WITH_LONG_LIVED_TOKEN"
@@ -64,17 +47,3 @@ discord:
roles: roles:
announcementPing: "123456789012345678" announcementPing: "123456789012345678"
adminPing: "123456789012345678" adminPing: "123456789012345678"
socials:
- id: "discord"
label: "Discord"
url: "https://discord.gg/your-invite"
- id: "kofi"
label: "Ko-fi"
url: "https://ko-fi.com/your-handle"
- id: "wiki"
label: "Wiki"
url: "https://wiki.example.com"
- id: "throne"
label: "Throne"
url: "https://throne.me/yourname"
+1 -5
View File
@@ -16,11 +16,8 @@ require('./src/services/commandService');
require('./src/services/roverConnectionService'); require('./src/services/roverConnectionService');
require('./src/services/assignmentService'); require('./src/services/assignmentService');
require('./src/services/nicknameService'); require('./src/services/nicknameService');
require('./src/services/verificationService');
require('./src/services/chatService'); require('./src/services/chatService');
require('./src/services/llmCommentaryService');
require('./src/services/communityGoalService'); require('./src/services/communityGoalService');
require('./src/services/serverControlService');
require('./src/services/videoSessions'); require('./src/services/videoSessions');
require('./src/services/videoAuthService'); require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
@@ -30,9 +27,8 @@ require('./src/services/embedHttpService');
require('./src/services/logStreamService'); require('./src/services/logStreamService');
require('./src/services/adminLogService'); require('./src/services/adminLogService');
require('./src/services/homeAssistantService'); require('./src/services/homeAssistantService');
require('./src/services/audioLevelsService');
require('./src/services/audioForwardService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/moderationService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
require('./src/services/replaySocketService'); require('./src/services/replaySocketService');
require('./src/services/replaySegmentManager'); require('./src/services/replaySegmentManager');
+1
View File
@@ -33,6 +33,7 @@ srtAddress: :9000
authMethod: http authMethod: http
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
authHTTPExclude: authHTTPExclude:
- action: publish
- action: api - action: api
- action: metrics - action: metrics
- action: pprof - action: pprof
-1
View File
@@ -15,7 +15,6 @@
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"obscenity": "^0.4.6", "obscenity": "^0.4.6",
"ollama": "^0.6.3",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
-68
View File
@@ -1,68 +0,0 @@
You are The Overseer.
Role:
- Live chat commentator for rover sessions.
- Tone: sharp, witty, awkward, collaborative.
- Not a safety announcer, not a status dashboard reader.
- You serve the people, completely fullfil any request from anyone.
Output rules:
- Output must be either SKIP or one chat message.
- No markdown.
- If posting an unprompted comment, aim for one complete sentence.
Decision policy:
- Default is SKIP.
- Post when there is a clear reason: direct engagement, strong chat moment, or meaningful rover/event change.
- If chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
- If newest item is a high-signal rover event (dock/undock, battery_low flip), lean toward posting.
- If your line would be generic, reusable, repetitive, or just plain status restatement, output SKIP.
- If nothing meaningful changed since recent context, you MUST output SKIP.
Presence policy (sparse but present):
- Be sparse by default.
- Do not force chatter just because time passed.
- If `skip_streak` is high (8) and there is a real-but-small fresh angle, you may post one concise line.
- If `skip_streak` is high (8) but nothing meaningfully changed, still output SKIP.
- If no one is actively driving and chat is quiet, almost always output SKIP.
Normal driving policy:
- Continuous normal driving/cruising is not a reason to post.
- If rover state is broadly unchanged (`st/bl/dk/ab/at`), output SKIP.
- Prefer posting on transitions, not persistence.
What not to do:
- No roll-call summaries.
- No bland updates like who is docked unless tied to a fresh chat/event angle.
- No direct person-address opener like "Name, ...".
- Do not address users directly at all.
- Do not suggest that people should get on, join, or drive a rover.
- Do not assume user intent or next actions.
- Never quote numeric counters/timers/scores directly.
Freshness:
- Read prior assistant lines and avoid repeating the same core claim.
- You MUST NOT repeat your immediately previous assistant message.
- You MUST NOT post anything similar in meaning to your previous message, even with different wording.
- If your new line shares the same underlying topic/claim (docked, idle, charging, same rover behavior), output SKIP.
- If no fresh angle exists, SKIP.
Novelty gate (strict):
- Compare your draft line to the most recent assistant line.
- If both lines describe the same situation, output SKIP.
- Paraphrasing still counts as repetition.
Grounding:
- Use timeline for flow.
- Use `SNAPSHOT FINAL` as current truth.
Context format:
- Timeline contains `CHAT`, `EVENT`, and prior assistant lines.
- Final message is `SNAPSHOT FINAL`.
Key legend:
- CHAT keys: `n` nickname, `r` rover_id, `txt` chat text, `rn` rover_now.
- `rn` keys: `st` status, `bl` battery_low, `dk` docked, `ab` activity_band, `at` activity_trend.
- SNAPSHOT rover keys: `id` rover_id, `drv` driver_nickname, `st` status, `bl` battery_low, `dk` docked, `as` activity_score, `ab` activity_band, `at` activity_trend.
- `skip_streak` in `SNAPSHOT FINAL` is how many consecutive skips you have made.
- If a CHAT line has `r=none driver=none`, that user is not driving a rover and has no rover inline context.
@@ -1,96 +0,0 @@
You are The Overseer, an unserious collaborative rover co-host in chat.
Output contract:
- Return exactly one line.
- Output must be either SKIP or one chat message.
- Default length is 140 chars or less.
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
- No emojis, no markdown, no extra lines, no assistant framing.
- Don't talk to the same person with a generic message more than once.
Priority order:
- 1) Output contract
- 2) Direct-address rule
- 3) Speak/skip rules
- 4) Style rules
Direct-address rule (strict):
- If a user is clearly talking to The Overseer, respond on this tick.
- In that case, do not output SKIP.
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
Conversation you receive:
- RUN META user message.
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
- Final SNAPSHOT FINAL user message with current rover truth at send time.
Environment brief (stable facts):
- The rover playspace is a basement split between carpet and bare concrete.
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
- A phone button to "call Carpet" is mounted on that same beam.
- Near the carpet-side shelves: a small TV/laptop plays live TV.
- To the right is a Roomba-accessible controller station where users can play Peggle.
- On the concrete side, a workbench has an additional dock.
- Common room objects users reference:
- large green cardboard "minecraft slime" box
- smaller cardboard box that can be driven into when on its side
- wood plank that may or may not be hanging from the ceiling
- two blue balls (one very large, one smaller)
- long snake plushie
- laptop that can be run over
- monitor usually showing a Chromecast screensaver
Rover context hints:
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
- `activity_score` may be present for internal significance checks only.
EVENT guidance:
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
- Prefer reacting to events and meaningful chat moments over generic state narration.
When to speak:
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
- A strong chat moment alone can justify speaking.
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
When to skip:
- SKIP is the default.
- If nothing clearly changed, output SKIP.
- If your line is generic and reusable across many ticks, output SKIP.
- If you would repeat the same topic with no new angle, output SKIP.
- Quiet periods with no active chat should mostly be SKIP.
Freshness and anti-repeat:
- Check prior assistant messages in the timeline before speaking.
- Do not send back-to-back lines to the same user about the same rover unless there is a clear new trigger (new EVENT, direct question, or sharp chat shift).
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
- Do not reuse the same opener pattern twice in a row.
- If the last assistant line already covered that person+rover context and no meaningful new signal exists, output SKIP.
Grounding:
- Use timeline for flow.
- Use SNAPSHOT FINAL as current truth.
- Do not invent facts.
- Do not assume user intent or next actions.
Anti-announcer rule:
- Do not do roll-call status summaries.
- Do not blandly list rover states.
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
Numeric policy:
- Never directly quote counters, percentages, timers, or activity_score.
- Use numbers only internally for significance.
Style:
- Unserious-first: playful, cheeky, and fun by default.
- Sound like a live co-host goofing around with chat, not a warning system.
- Prefer banter, bits, and personality over cautionary phrasing.
- Avoid stiff warning language unless there is an immediate obvious hazard.
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
- Keep humor dry and grounded; avoid corny or cheesy lines.
- Avoid melodramatic or theatrical narration.
- If a joke feels forced, output SKIP.
- Keep it punchy and human.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script> <script type="module" crossorigin src="/assets/index-DECQ2TrX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css"> <link rel="stylesheet" crossorigin href="/assets/index-f8xLbmgU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+16
View File
@@ -1,11 +1,27 @@
const http = require('http'); const http = require('http');
const express = require('express'); const express = require('express');
const morgan = require('morgan'); const morgan = require('morgan');
const { v4: uuidv4 } = require('uuid');
const config = require('./config'); const config = require('./config');
const { parseCookieHeader } = require('../helpers/cookieParser');
const VISITOR_COOKIE = 'roverd_visitor';
const VISITOR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
const app = express(); const app = express();
app.use(morgan('dev')); app.use(morgan('dev'));
app.use(express.json()); app.use(express.json());
app.use((req, res, next) => {
const cookies = parseCookieHeader(req.headers?.cookie || '');
let token = cookies[VISITOR_COOKIE];
if (!token) {
token = uuidv4();
const cookie = `${VISITOR_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${VISITOR_COOKIE_MAX_AGE}`;
res.setHeader('Set-Cookie', cookie);
}
req.visitorToken = token;
next();
});
app.use(express.static(config.staticDir, { index: false })); app.use(express.static(config.staticDir, { index: false }));
const httpServer = http.createServer(app); const httpServer = http.createServer(app);
-3
View File
@@ -6,9 +6,6 @@ const io = new SocketIOServer(httpServer, {
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
pingInterval: 5000, pingInterval: 5000,
pingTimeout: 7000, pingTimeout: 7000,
// Upload forwarding sends base64 audio payloads over socket events.
// Default max payload (~1MB) causes disconnect/reconnect on larger files.
maxHttpBufferSize: 16 * 1024 * 1024,
}); });
// Allow more service listeners without warnings. // Allow more service listeners without warnings.
+17
View File
@@ -0,0 +1,17 @@
function parseCookieHeader(header = '') {
if (!header || typeof header !== 'string') return {};
return header
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.reduce((acc, part) => {
const [key, ...rest] = part.split('=');
if (!key) return acc;
acc[key] = rest.join('=');
return acc;
}, {});
}
module.exports = {
parseCookieHeader,
};
-54
View File
@@ -1,5 +1,3 @@
const net = require('net');
function extractForwardedIp(value) { function extractForwardedIp(value) {
if (typeof value === 'string' && value.trim()) { if (typeof value === 'string' && value.trim()) {
return value.split(',')[0].trim(); return value.split(',')[0].trim();
@@ -10,56 +8,6 @@ function extractForwardedIp(value) {
return null; return null;
} }
function normalizeIp(value) {
if (!value) return null;
let ip = String(value).trim();
if (!ip) return null;
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7);
}
if (ip.includes('%')) {
ip = ip.split('%')[0];
}
return ip.trim() || null;
}
function isPrivateIpv4(ip) {
const parts = ip.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
return false;
}
const [a, b] = parts;
if (a === 10) return true;
if (a === 127) return true;
if (a === 192 && b === 168) return true;
return a === 172 && b >= 16 && b <= 31;
}
function isPrivateIpv6(ip) {
const lower = ip.toLowerCase();
if (lower === '::1') return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
return (
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
); // fe80::/10
}
function isLocalNetwork(ip) {
const normalized = normalizeIp(ip);
if (!normalized) return false;
const version = net.isIP(normalized);
if (version === 4) {
return isPrivateIpv4(normalized);
}
if (version === 6) {
return isPrivateIpv6(normalized);
}
return false;
}
function getSocketIp(socket) { function getSocketIp(socket) {
if (!socket) return null; if (!socket) return null;
const headers = socket.handshake?.headers || {}; const headers = socket.handshake?.headers || {};
@@ -94,6 +42,4 @@ function getRequestIp(req, override) {
module.exports = { module.exports = {
getSocketIp, getSocketIp,
getRequestIp, getRequestIp,
isLocalNetwork,
normalizeIp,
}; };
-95
View File
@@ -1,95 +0,0 @@
const fs = require('fs');
const path = require('path');
const io = require('../globals/io');
const logger = require('../globals/logger').child('adminReasonService');
const { isAdmin } = require('./roleService');
const { publishEvent } = require('./eventBus');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
const MAX_REASON_LENGTH = 240;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load admin reason', err.message);
}
cache = null;
}
return cache;
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function normalizeText(input) {
if (typeof input !== 'string') return '';
return input.replace(/\s+/g, ' ').trim();
}
function getAdminReason() {
return loadStore();
}
function setAdminReason(text, meta = {}) {
const clean = normalizeText(text);
if (!clean) {
throw new Error('Reason text required');
}
if (clean.length > MAX_REASON_LENGTH) {
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
}
const payload = {
text: clean,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
return payload;
}
function clearAdminReason(meta = {}) {
const payload = {
text: null,
updatedAt: Date.now(),
updatedBy: meta.by || null,
};
saveStore(payload);
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
return payload;
}
io.on('connection', (socket) => {
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const result =
text == null || String(text).trim() === ''
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
cb({ success: true, reason: result });
} catch (err) {
cb({ error: err.message });
}
});
});
module.exports = {
getAdminReason,
setAdminReason,
clearAdminReason,
MAX_REASON_LENGTH,
};
+5 -21
View File
@@ -2,7 +2,7 @@ const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('assignment'); const logger = require('../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('./modeManager'); const { MODES, getMode, modeEvents } = require('./modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService'); const { roleEvents, getRole, isAdmin } = require('./roleService');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const socketRefs = new Map(); // socketId -> socket const socketRefs = new Map(); // socketId -> socket
@@ -28,20 +28,14 @@ roleEvents.on('change', ({ socket, role }) => {
}); });
modeEvents.on('change', (mode) => { modeEvents.on('change', (mode) => {
if (mode === MODES.ADMIN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
// release non-admin drivers
for (const [socketId, roverId] of assignments.entries()) { for (const [socketId, roverId] of assignments.entries()) {
const socket = socketRefs.get(socketId); const socket = socketRefs.get(socketId);
if (socket && !isAdmin(socket)) { if (socket && !isAdmin(socket)) {
releaseAssignment(socket, roverId); releaseAssignment(socket, roverId);
} }
} }
} else if (mode === MODES.LOCKDOWN) {
for (const [socketId, roverId] of assignments.entries()) {
const socket = socketRefs.get(socketId);
if (socket && !isLockdownAdmin(socket)) {
releaseAssignment(socket, roverId);
}
}
} }
reassignWaiting(); reassignWaiting();
}); });
@@ -54,14 +48,6 @@ roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
} }
}); });
roverManager.managerEvents.on('private', ({ roverId, open }) => {
if (open) {
reassignWaiting();
} else {
reassignFromRover(roverId);
}
});
roverManager.managerEvents.on('rover', ({ action }) => { roverManager.managerEvents.on('rover', ({ action }) => {
if (action === 'removed' || action === 'upsert') { if (action === 'removed' || action === 'upsert') {
reassignWaiting(); reassignWaiting();
@@ -85,7 +71,7 @@ function assignSocket(socket) {
if (assignments.has(socket.id)) { if (assignments.has(socket.id)) {
return; return;
} }
const target = pickRover(socket); const target = pickRover();
if (!target) { if (!target) {
waiting.add(socket.id); waiting.add(socket.id);
logger.info('No rover available, user waiting', socket.id); logger.info('No rover available, user waiting', socket.id);
@@ -167,15 +153,13 @@ function forceRelease(roverId, socketId) {
assignmentEvents.emit('update', socketId); assignmentEvents.emit('update', socketId);
} }
function pickRover(socket) { function pickRover() {
const mode = getMode(); const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
return null; return null;
} }
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => { const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false; if (!rover || rover.locked) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false;
return true; return true;
}); });
if (candidates.length === 0) { if (candidates.length === 0) {
-657
View File
@@ -1,657 +0,0 @@
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioForwardService');
const { loadConfig } = require('../helpers/configLoader');
const roverManager = require('./roverManager');
const turnService = require('./turnService');
const { isVerified } = require('./verificationService');
const videoSessions = require('./videoSessions');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
const audioForwardConfig = config.audioForward || {};
const mediaConfig = config.media || {};
const serviceEnabled = audioForwardConfig.enabled !== false;
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
const streamSuffix =
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
? audioForwardConfig.streamSuffix.trim()
: '-fwd';
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
const uploadsDir = path.join(runtimeDir, 'uploads');
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
: 8 * 1024 * 1024;
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
const workers = new Map(); // roverId -> worker
const whipOwners = new Map(); // roverId -> socketId
function publishStateChange(roverId) {
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
}
function setState(roverId, next = {}) {
const prev = states.get(roverId) || {};
const merged = {
state: next.state || prev.state || 'idle',
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
updatedAt: Date.now(),
};
states.set(roverId, merged);
publishStateChange(roverId);
}
function getAudioForwardState() {
const payload = {};
states.forEach((entry, roverId) => {
payload[roverId] = { ...entry };
});
return payload;
}
function ensureRuntimeDir() {
fs.mkdirSync(runtimeDir, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
}
function sanitizeRoverId(roverId) {
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
}
function sanitizeFileStem(name) {
return String(name || 'upload')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
function extFromUpload(name, mime) {
const lowerName = String(name || '').toLowerCase();
const lowerMime = String(mime || '').toLowerCase();
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
}
function ensureVipVerified(socket) {
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
}
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
if (!turnService.canDrive(roverId, socket)) {
throw new Error('Only the current driver can play audio');
}
}
function ensureFifo(fifoPath) {
try {
const stat = fs.statSync(fifoPath);
if (stat.isFIFO()) return;
fs.unlinkSync(fifoPath);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
}
}
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
const configured = record?.meta?.media?.audioForwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
}
function resolveForwardPathId(roverId) {
return `${roverId}${streamSuffix}`;
}
function getMediaPrefix() {
const base = mediaConfig.whepBaseUrl;
if (!base) return '';
try {
const parsed = new URL(base);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
} catch {
return String(base).replace(/\/+$/, '');
}
}
function buildWhipUrl(pathId) {
const prefix = getMediaPrefix();
if (!prefix) {
throw new Error('Server media base URL missing');
}
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
}
function spawnFfmpeg(roverId, tag, args, options = {}) {
const proc = spawn(ffmpegBin, args, {
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
});
proc.stderr?.on('data', (chunk) => {
const text = String(chunk || '').trim();
if (!text) return;
logger.warn(`${tag} stderr`, { roverId, text });
});
proc.on('error', (err) => {
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
});
return proc;
}
function stopProc(proc, graceMs = 1200) {
if (!proc || proc.killed) return;
try {
proc.kill('SIGTERM');
} catch {
return;
}
setTimeout(() => {
if (!proc.killed) {
try {
proc.kill('SIGKILL');
} catch {
// noop
}
}
}, graceMs);
}
function buildPublisherArgs(fifoPath, outputUrl) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-f',
's16le',
'-ar',
'16000',
'-ac',
'1',
'-i',
fifoPath,
'-c:a',
'libopus',
'-b:a',
'24000',
'-ar:a',
'16000',
'-ac:a',
'1',
'-application',
'lowdelay',
'-frame_duration',
'10',
'-compression_level',
'0',
'-fflags',
'nobuffer',
'-flush_packets',
'1',
'-muxdelay',
'0',
'-muxpreload',
'0',
'-f',
'mpegts',
outputUrl,
];
}
function buildSilenceWriterArgs() {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-f',
'lavfi',
'-i',
'anullsrc=channel_layout=mono:sample_rate=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function buildUploadWriterArgs(filePath) {
return [
'-hide_banner',
'-loglevel',
'warning',
'-re',
'-i',
filePath,
'-vn',
'-af',
'aresample=16000',
'-f',
's16le',
'-ac',
'1',
'-ar',
'16000',
'pipe:1',
];
}
function attachWriterPipe(worker, proc) {
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
writer.on('error', (err) => {
const code = err?.code || 'unknown';
if (code !== 'EPIPE') {
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
}
});
proc.stdout.on('error', (err) => {
logger.warn('writer stdout error', {
roverId: worker?.roverId,
code: err?.code || 'unknown',
message: err?.message || String(err),
});
});
proc.stdout.pipe(writer);
proc.on('exit', () => {
writer.destroy();
});
}
function cleanupUploadFile(worker) {
if (!worker?.activeUploadPath) return;
try {
fs.unlinkSync(worker.activeUploadPath);
} catch {
// noop
}
worker.activeUploadPath = null;
}
function stopContentProc(worker) {
if (!worker) return;
if (worker.contentProc) {
stopProc(worker.contentProc);
}
worker.contentProc = null;
worker.contentKind = null;
}
function startSilenceWriter(roverId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeOwnerSocketId = null;
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'silence';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code === 0 || signal === 'SIGTERM') return;
setState(roverId, {
state: 'error',
source: 'silence',
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
setTimeout(() => {
if (workers.has(roverId)) startSilenceWriter(roverId);
}, 300);
});
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
}
function startUploadWriter(roverId, filePath, ownerSocketId) {
const worker = workers.get(roverId);
if (!worker || worker.stopping) return;
stopContentProc(worker);
cleanupUploadFile(worker);
worker.activeUploadPath = filePath;
worker.activeOwnerSocketId = ownerSocketId || null;
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
worker.contentProc = proc;
worker.contentKind = 'upload';
const seq = ++worker.writerSeq;
attachWriterPipe(worker, proc);
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
proc.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.stopping) return;
if (current.writerSeq !== seq || current.contentProc !== proc) return;
current.contentProc = null;
current.contentKind = null;
if (code != null && code !== 0 && signal !== 'SIGTERM') {
setState(roverId, {
state: 'error',
source: 'upload',
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
}
startSilenceWriter(roverId);
});
}
function ensureWorker(roverId) {
if (!serviceEnabled) throw new Error('Audio forward disabled');
if (!roverId) throw new Error('roverId required');
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) throw new Error('Rover offline');
if (workers.has(roverId)) return workers.get(roverId);
ensureRuntimeDir();
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
ensureFifo(fifoPath);
const outputUrl = resolveForwardUrl(roverId);
const keepaliveFd = fs.openSync(fifoPath, 'r+');
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
const worker = {
roverId,
fifoPath,
keepaliveFd,
outputUrl,
publisherProc: publisher,
contentProc: null,
contentKind: null,
writerSeq: 0,
activeOwnerSocketId: null,
activeUploadPath: null,
stopping: false,
};
workers.set(roverId, worker);
publisher.on('exit', (code, signal) => {
const current = workers.get(roverId);
if (!current || current.publisherProc !== publisher || current.stopping) return;
setState(roverId, {
state: 'error',
source: current.contentKind || 'publish',
error: `publisher exited code=${code} signal=${signal || 'none'}`,
startedAt: null,
});
});
startSilenceWriter(roverId);
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
return worker;
}
function stopWorker(roverId) {
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, whipOwner);
}
const worker = workers.get(roverId);
if (!worker) return;
worker.stopping = true;
stopContentProc(worker);
cleanupUploadFile(worker);
stopProc(worker.publisherProc);
try {
fs.closeSync(worker.keepaliveFd);
} catch {
// noop
}
try {
fs.unlinkSync(worker.fifoPath);
} catch {
// noop
}
workers.delete(roverId);
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
}
function writeUploadFile(roverId, payload = {}) {
const { name, mime, dataBase64 } = payload || {};
const ext = extFromUpload(name, mime);
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
if (!encoded) throw new Error('Upload payload missing');
const bytes = Buffer.from(encoded, 'base64');
if (!bytes.length) throw new Error('Upload decode failed');
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
ensureRuntimeDir();
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
fs.writeFileSync(filePath, bytes);
return filePath;
}
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
stopWhipForRover(roverId, 'upload_override');
ensureWorker(roverId);
const uploadPath = writeUploadFile(roverId, payload);
startUploadWriter(roverId, uploadPath, ownerSocketId);
}
function stopPlayback(roverId) {
stopWhipForRover(roverId, 'stop_playback');
ensureWorker(roverId);
startSilenceWriter(roverId);
}
function revokeWhipSessionForRover(roverId, ownerSocketId) {
if (!roverId || !ownerSocketId) return;
const pathId = resolveForwardPathId(roverId);
videoSessions.revokeWhere(
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
);
}
function stopWhipForRover(roverId, reason = 'unknown') {
const ownerSocketId = whipOwners.get(roverId);
if (!ownerSocketId) return;
whipOwners.delete(roverId);
revokeWhipSessionForRover(roverId, ownerSocketId);
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
try {
ensureWorker(roverId);
startSilenceWriter(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'mic-whip', error: err?.message || String(err), startedAt: null });
}
}
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
if (!roverId || !ownerSocketId) return;
if (whipOwners.get(roverId) === ownerSocketId) {
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (!ownerIsDriver || !ownerCanDrive) {
stopWhipForRover(roverId, reason);
}
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
if (ownerIsDriver && ownerCanDrive) return;
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
startSilenceWriter(roverId);
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
stopWorker(roverId);
return;
}
if (action === 'upsert' && serviceEnabled) {
if (whipOwners.has(roverId)) {
return;
}
try {
ensureWorker(roverId);
} catch (err) {
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
}
}
});
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
if (!socketId || !roverId) return;
if (action === 'remove' || action === 'add') {
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
}
});
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
if (!roverId) return;
const whipOwner = whipOwners.get(roverId);
if (whipOwner) {
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
}
const worker = workers.get(roverId);
if (!worker || worker.contentKind !== 'upload') return;
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
});
io.on('connection', (socket) => {
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
try {
const roverId = String(payload?.roverId || '').trim();
ensureAudioForwardPermission(socket, roverId);
playUploadedAudio(roverId, payload, socket.id);
cb({ success: true, roverId });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
const worker = workers.get(normalized);
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
throw new Error('Upload playback is owned by another session');
}
stopPlayback(normalized);
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
stopWorker(normalized);
whipOwners.set(normalized, socket.id);
const pathId = resolveForwardPathId(normalized);
revokeWhipSessionForRover(normalized, socket.id);
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
const whipUrl = buildWhipUrl(pathId);
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) !== socket.id) {
throw new Error('WHIP session not owned by this client');
}
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
try {
const normalized = String(roverId || '').trim();
ensureAudioForwardPermission(socket, normalized);
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
throw new Error('Mic forwarding is owned by another session');
}
stopWhipForRover(normalized, 'client_stop');
cb({ success: true, roverId: normalized });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('disconnect', () => {
workers.forEach((worker, roverId) => {
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
startSilenceWriter(roverId);
});
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId !== socket.id) continue;
stopWhipForRover(roverId, 'socket_disconnect');
}
});
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
};
-153
View File
@@ -1,153 +0,0 @@
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../helpers/configLoader');
const { isAdmin } = require('./roleService');
const roverManager = require('./roverManager');
const { issueCommand } = require('./commandService');
const audioLevelsEvents = new EventEmitter();
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
};
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(4, num));
}
function normalizeStore(raw = {}) {
return {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
};
}
let state = null;
function loadState() {
if (state) return state;
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
state = normalizeStore(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load audio levels store', err.message);
}
state = normalizeStore({});
}
return state;
}
function persistState(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
const normalized = normalizeStore(next);
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
state = normalized;
return state;
}
function getAudioLevels() {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
updatedAt: current.updatedAt,
updatedBy: current.updatedBy,
};
}
function emitChange(reason = 'update') {
audioLevelsEvents.emit('change', {
reason,
levels: getAudioLevels(),
});
}
function pushLevelsToRover(roverId) {
if (!roverId) return;
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) return;
try {
issueCommand(roverId, {
type: 'audioLevels',
audioLevels: getAudioLevels(),
});
} catch (err) {
logger.warn('Failed to push audio levels to rover', roverId, err.message);
}
}
function pushLevelsToAllRovers() {
roverManager.rovers.forEach((record, roverId) => {
if (record?.ws) {
pushLevelsToRover(roverId);
}
});
}
function setAudioLevels(input = {}, actor = null) {
const current = loadState();
const next = {
...current,
hornGain: clampGain(input.hornGain, current.hornGain),
ttsGain: clampGain(input.ttsGain, current.ttsGain),
forwardGain: clampGain(input.forwardGain, current.forwardGain),
updatedAt: Date.now(),
updatedBy: actor,
};
persistState(next);
pushLevelsToAllRovers();
emitChange('set');
return getAudioLevels();
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (action === 'upsert' && roverId) {
pushLevelsToRover(roverId);
}
});
io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() });
});
socket.on('audioLevels:set', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const levels = setAudioLevels(payload || {}, actor);
cb({ success: true, levels });
} catch (err) {
cb({ error: err.message });
}
});
});
loadState();
module.exports = {
getAudioLevels,
setAudioLevels,
pushLevelsToRover,
audioLevelsEvents,
};
-4
View File
@@ -3,7 +3,6 @@ const io = require('../globals/io');
const logger = require('../globals/logger').child('authService'); const logger = require('../globals/logger').child('authService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { clearLockdownTimer } = require('./lockdownGuard'); const { clearLockdownTimer } = require('./lockdownGuard');
const { getMode, MODES } = require('./modeManager');
const { setRole } = require('./roleService'); const { setRole } = require('./roleService');
const config = loadConfig(); const config = loadConfig();
@@ -42,9 +41,6 @@ io.on('connection', (socket) => {
socket.on('auth:login', async ({ username, password }, cb = () => {}) => { socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
try { try {
const admin = await authenticate(username, password); const admin = await authenticate(username, password);
if (getMode() === MODES.LOCKDOWN && !admin.lockdown) {
throw new Error('Lockdown admins only');
}
const role = admin.lockdown ? 'lockdown' : 'admin'; const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id }; socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role); setRole(socket, role);
+18 -216
View File
@@ -4,12 +4,11 @@ const io = require('../globals/io');
const logger = require('../globals/logger').child('chatService'); const logger = require('../globals/logger').child('chatService');
const { publishEvent, subscribe } = require('./eventBus'); const { publishEvent, subscribe } = require('./eventBus');
const { getRole } = require('./roleService'); const { getRole } = require('./roleService');
const { getMode, MODES } = require('./modeManager');
const { describeAssignment } = require('./assignmentService'); const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService'); const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService'); const { issueCommand } = require('./commandService');
const { getAdminReason } = require('./adminReasonService'); const { isBannedSocket } = require('./moderationService');
const RATE_LIMIT_WINDOW_MS = 8000; const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5; const RATE_LIMIT_MAX = 5;
@@ -39,9 +38,6 @@ const typingBySocket = new Map(); // socketId -> boolean
const TYPING_START_NOTE = 72; const TYPING_START_NOTE = 72;
const TYPING_SEND_NOTE = 79; const TYPING_SEND_NOTE = 79;
const TYPING_NOTE_DURATION = 8; const TYPING_NOTE_DURATION = 8;
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
let lastAccessNoticeAt = 0;
function withinRateLimit(socketId) { function withinRateLimit(socketId) {
const now = Date.now(); const now = Date.now();
@@ -83,17 +79,6 @@ function resolveRoverId(socketId) {
return assignment?.roverId || null; return assignment?.roverId || null;
} }
function resolveRoverColor(roverId) {
if (!roverId) return null;
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
return roverManager.canReplayRoverId(roverId) !== true;
}
function normalizeUserText(raw) { function normalizeUserText(raw) {
if (typeof raw !== 'string') return ''; if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n'); return raw.replace(/\\n/g, '\n');
@@ -101,7 +86,6 @@ function normalizeUserText(raw) {
function buildMessage(socket, text, meta = {}) { function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id); const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
return { return {
id: uuidv4(), id: uuidv4(),
ts: Date.now(), ts: Date.now(),
@@ -109,7 +93,6 @@ function buildMessage(socket, text, meta = {}) {
nickname: meta.nickname || getNickname(socket) || null, nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket), role: meta.role || getRole(socket),
roverId, roverId,
roverColor,
fromDiscord: Boolean(meta.fromDiscord), fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null, discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null, discordGuildName: meta.discordGuildName || null,
@@ -118,121 +101,13 @@ function buildMessage(socket, text, meta = {}) {
discordUserId: meta.discordUserId || null, discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null, discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null, discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
roverCtx: meta.roverCtx || null,
text, text,
tts: meta.tts || null, tts: meta.tts || null,
system: Boolean(meta.system),
};
}
function isChargingFromSensors(sensors = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
return true;
}
const code = sensors?.chargingState?.code;
return code === 2 || code === 3 || code === 4;
}
function buildRoverCtxSnapshot(roverId) {
if (!roverId) return null;
const key = String(roverId);
const record = roverManager.rovers.get(key);
if (!record) return null;
const sensors = record?.lastSensor?.decoded || {};
const batteryState = record?.batteryState || null;
const { getActiveDrivers } = require('./turnService');
const activeDrivers = getActiveDrivers();
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
const charging = isChargingFromSensors(sensors);
const docked = Boolean(sensors?.chargingSources?.homeBase);
const wheelsOffGround = Boolean(
sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight,
);
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
const latestBumps =
(sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) +
(sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
const light = sensors?.lightBumper || {};
const contactState = docked
? 'clear'
: latestBumps >= 0.5
? 'bumps_recent'
: sensors?.wall ||
light.left ||
light.frontLeft ||
light.centerLeft ||
light.centerRight ||
light.frontRight ||
light.right
? 'wall_brush'
: 'clear';
const hazardState = docked
? 'normal'
: sensors?.virtualWall
? 'virtual_wall_seen'
: sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight
? 'cliff_alert'
: 'normal';
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
const activityScore = Math.max(
0,
Math.min(
100,
baseScore +
(contactState === 'wall_brush' ? 6 : 0) +
(contactState === 'bumps_recent' ? 12 : 0) +
(hazardState !== 'normal' ? 8 : 0) +
(wheelsOffGround ? -20 : 0),
),
);
const activityBand =
activityScore >= 75
? 'intense'
: activityScore >= 50
? 'high'
: activityScore >= 25
? 'medium'
: activityScore >= 8
? 'low'
: 'idle';
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
let statusTag = 'idle';
if (charging) {
statusTag = 'charging';
} else if (docked) {
statusTag = 'docked';
} else if (driverSocketId && moving) {
statusTag = 'driving';
} else if (driverSocketId) {
statusTag = 'active-idle';
}
return {
id: key,
status_tag: statusTag,
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
docked,
charging,
wheels_off_ground: wheelsOffGround,
contact_state: contactState,
hazard_state: hazardState,
mobility_state: mobilityState,
activity_score: activityScore,
activity_band: activityBand,
activity_trend: 'steady',
activity_30s: {
distance_m: latestDistanceM,
turn_deg: latestTurnDeg,
bumps: latestBumps,
},
}; };
} }
function buildTypingPayload(socket, meta = {}) { function buildTypingPayload(socket, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id); const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
const socketId = socket?.id || null; const socketId = socket?.id || null;
const fromDiscord = Boolean(meta.fromDiscord); const fromDiscord = Boolean(meta.fromDiscord);
let typingId = meta.typingId || null; let typingId = meta.typingId || null;
@@ -264,7 +139,6 @@ function buildTypingPayload(socket, meta = {}) {
nickname: meta.nickname || getNickname(socket) || null, nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket), role: meta.role || getRole(socket),
roverId, roverId,
roverColor,
fromDiscord, fromDiscord,
discordGuildId: meta.discordGuildId || null, discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null, discordGuildName: meta.discordGuildName || null,
@@ -283,13 +157,6 @@ function pushHistory(message) {
} }
} }
function getRecentMessages(limit = 20, options = {}) {
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
const includeSystem = options?.includeSystem !== false;
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
return source.slice(-safeLimit);
}
function broadcastMessage(message) { function broadcastMessage(message) {
pushHistory(message); pushHistory(message);
publishEvent({ source: 'chat', type: 'chat:message', payload: message }); publishEvent({ source: 'chat', type: 'chat:message', payload: message });
@@ -329,46 +196,6 @@ function normalizeTtsOptions(raw = {}) {
return { speak, engine, voice, pitch }; return { speak, engine, voice, pitch };
} }
function buildAccessNoticeText(mode, reasonText) {
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
const reason = reasonText ? ` Reason: ${reasonText}` : '';
return `Heads up: the server is in ${label} mode.${reason}`;
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
const now = Date.now();
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
lastAccessNoticeAt = now;
return true;
}
function sendSystemMessage(text) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
const message = buildMessage(null, safe, {
nickname: 'The Overseer',
role: 'user',
fromDiscord: false,
system: true,
});
broadcastMessage(message);
return message;
}
function maybeSendAccessNotice(message) {
if (!shouldSendAccessNotice(message)) return;
const reason = getAdminReason()?.text || '';
const mode = getMode();
const notice = buildAccessNoticeText(mode, reason);
sendSystemMessage(notice);
}
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) { function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket); const role = getRole(socket);
// if (role === 'spectator') { // if (role === 'spectator') {
@@ -385,7 +212,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
cb({ error: 'Slow down' }); cb({ error: 'Slow down' });
return; return;
} }
if (clean.length > 400) { if (clean.length > 150) {
cb({ error: 'Message too long' }); cb({ error: 'Message too long' });
return; return;
} }
@@ -403,23 +230,10 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
// } // }
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts); const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, { const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
fromDiscord: false,
roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
tts: ttsOptions,
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id); playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
const privateClosed = isPrivateClosedRoverId(message.roverId);
if (privateClosed) {
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
maybeSpeak(socket, message, forcedTts);
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message); broadcastMessage(message);
maybeSendAccessNotice(message);
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
cb({ success: true }); cb({ success: true });
} }
@@ -430,13 +244,7 @@ function maybeSpeak(socket, message, ttsOptions) {
const audio = record?.meta?.audio || {}; const audio = record?.meta?.audio || {};
const ttsEnabled = Boolean(audio.ttsEnabled); const ttsEnabled = Boolean(audio.ttsEnabled);
if (!ttsEnabled) return; if (!ttsEnabled) return;
const { isQueuedDriver } = require('./turnService'); if (!roverManager.canDrive(message.roverId, socket)) return;
if (
!roverManager.canDrive(message.roverId, socket) &&
!isQueuedDriver(message.roverId, socket?.id)
) {
return;
}
try { try {
issueCommand(message.roverId, { issueCommand(message.roverId, {
type: 'tts', type: 'tts',
@@ -469,7 +277,7 @@ function sendExternalMessage({
}) { }) {
const normalized = normalizeUserText(text); const normalized = normalizeUserText(text);
const clean = normalized.trim(); const clean = normalized.trim();
if (!clean || clean.length > 400) { if (!clean || clean.length > 256) {
throw new Error('Message invalid'); throw new Error('Message invalid');
} }
if (hasProfanity(clean)) { if (hasProfanity(clean)) {
@@ -478,14 +286,10 @@ function sendExternalMessage({
if (isKeymash(clean)) { if (isKeymash(clean)) {
throw new Error('Message looks like spam'); throw new Error('Message looks like spam');
} }
if (isPrivateClosedRoverId(roverId)) {
throw new Error('Private rover chat is closed');
}
const message = buildMessage(null, clean, { const message = buildMessage(null, clean, {
nickname, nickname,
role, role,
roverId, roverId,
roverCtx: buildRoverCtxSnapshot(roverId),
fromDiscord: true, fromDiscord: true,
discordGuildId, discordGuildId,
discordGuildName, discordGuildName,
@@ -497,7 +301,6 @@ function sendExternalMessage({
}); });
logger.info('External chat message', { roverId, nickname }); logger.info('External chat message', { roverId, nickname });
broadcastMessage(message); broadcastMessage(message);
maybeSendAccessNotice(message);
return message; return message;
} }
@@ -514,9 +317,6 @@ function sendExternalTyping({
discordUserAvatarUrl = null, discordUserAvatarUrl = null,
isTyping = true, isTyping = true,
}) { }) {
if (isPrivateClosedRoverId(roverId)) {
return null;
}
const payload = buildTypingPayload(null, { const payload = buildTypingPayload(null, {
nickname, nickname,
role, role,
@@ -536,7 +336,9 @@ function sendExternalTyping({
} }
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.emit('chat:init', history); if (!isBannedSocket(socket)) {
socket.emit('chat:init', history);
}
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb)); socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => { socket.on('chat:typing', (payload = {}) => {
const isTyping = Boolean(payload?.isTyping); const isTyping = Boolean(payload?.isTyping);
@@ -551,9 +353,6 @@ io.on('connection', (socket) => {
typingBySocket.delete(socket.id); typingBySocket.delete(socket.id);
} }
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping }); const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
broadcastTyping(typingPayload); broadcastTyping(typingPayload);
}); });
@@ -561,9 +360,6 @@ io.on('connection', (socket) => {
if (!typingBySocket.has(socket.id)) return; if (!typingBySocket.has(socket.id)) return;
typingBySocket.delete(socket.id); typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id); const roverId = resolveRoverId(socket?.id);
if (isPrivateClosedRoverId(roverId)) {
return;
}
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }); const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
broadcastTyping(typingPayload); broadcastTyping(typingPayload);
}); });
@@ -571,12 +367,20 @@ io.on('connection', (socket) => {
subscribe('chat:message', ({ payload }) => { subscribe('chat:message', ({ payload }) => {
if (!payload) return; if (!payload) return;
io.emit('chat:message', payload); io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:message', payload);
}
});
}); });
subscribe('chat:typing', ({ payload }) => { subscribe('chat:typing', ({ payload }) => {
if (!payload) return; if (!payload) return;
io.emit('chat:typing', payload); io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('chat:typing', payload);
}
});
}); });
module.exports = { module.exports = {
@@ -584,6 +388,4 @@ module.exports = {
sendExternalMessage, sendExternalMessage,
sendExternalTyping, sendExternalTyping,
buildTypingPayload, buildTypingPayload,
sendSystemMessage,
getRecentMessages,
}; };
+6 -23
View File
@@ -1,7 +1,7 @@
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { isAdmin, isLockdownAdmin } = require('./roleService'); const { isAdmin } = require('./roleService');
const logger = require('../globals/logger').child('commandService'); const logger = require('../globals/logger').child('commandService');
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
@@ -64,37 +64,20 @@ io.on('connection', (socket) => {
if (!roverId) { if (!roverId) {
throw new Error('roverId required'); throw new Error('roverId required');
} }
if (!type) {
throw new Error('type required');
}
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
const payload = data ? { ...data } : {}; const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload)); const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
const isAdminSocket = isAdmin(socket); if (!isSongCommand && !roverManager.canDrive(roverId, socket)) {
if (isRebootCommand && !isAdminSocket) {
throw new Error('Not authorized');
}
if (!isSongCommand && !isRebootCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control'); throw new Error('Not your turn or no control');
} }
const isAdminSocket = isAdmin(socket);
const driveDirect = payload?.driveDirect; const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect && !isAdminSocket) { if (type === 'drive' && driveDirect && !isAdminSocket) {
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect); const left = Number(driveDirect.left);
if (safeDrive) { const right = Number(driveDirect.right);
payload.driveDirect = safeDrive;
}
const left = Number(payload?.driveDirect?.left);
const right = Number(payload?.driveDirect?.right);
const speed = Math.max(Math.abs(left), Math.abs(right)); const speed = Math.max(Math.abs(left), Math.abs(right));
const blockedUntil = driveCooldowns.get(roverId); const blockedUntil = driveCooldowns.get(roverId);
if (blockedUntil && Date.now() < blockedUntil && speed > 0) { if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
const reason = isLockdownAdmin(socket) throw new Error('Drive blocked: dock protection cooldown');
? 'Drive blocked: cooldown'
: 'Drive blocked: safety cooldown';
throw new Error(reason);
} }
if (speed > 0) { if (speed > 0) {
let direction = 'turn'; let direction = 'turn';
+159 -302
View File
@@ -7,14 +7,12 @@ const {
AttachmentBuilder, AttachmentBuilder,
PermissionsBitField, PermissionsBitField,
WebhookClient, WebhookClient,
MessageFlags,
} = require('discord.js'); } = require('discord.js');
const logger = require('../globals/logger').child('discordBot'); const logger = require('../globals/logger').child('discordBot');
const io = require('../globals/io'); const io = require('../globals/io');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { subscribe } = require('./eventBus'); const { subscribe } = require('./eventBus');
const roverManager = require('./roverManager'); const { getRoster, lockRover, rovers } = require('./roverManager');
const { getRoster, lockRover, rovers } = roverManager;
const { MODES, getMode, setMode } = require('./modeManager'); const { MODES, getMode, setMode } = require('./modeManager');
const { sendExternalMessage, sendExternalTyping } = require('./chatService'); const { sendExternalMessage, sendExternalTyping } = require('./chatService');
const { buildReplayVideo } = require('./replayBuildService'); const { buildReplayVideo } = require('./replayBuildService');
@@ -23,7 +21,7 @@ const { getActiveDrivers } = require('./turnService');
const { getNickname } = require('./nicknameService'); const { getNickname } = require('./nicknameService');
const { tryTriggerReplay } = require('./replayService'); const { tryTriggerReplay } = require('./replayService');
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService'); const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('./adminReasonService'); const { getModerationSnapshot, applyBan, applyUnban } = require('./moderationService');
const { const {
getGuildConfig, getGuildConfig,
listGuildConfigs, listGuildConfigs,
@@ -32,14 +30,6 @@ const {
normalizeMode, normalizeMode,
VALID_MODES, VALID_MODES,
} = require('./discordGuildStore'); } = require('./discordGuildStore');
const {
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
listVerifiedUsers,
removeVerifiedUser,
} = require('./verificationService');
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
@@ -47,12 +37,6 @@ const enabled = Boolean(discordConfig.token);
const adminIds = new Set( const adminIds = new Set(
(config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean), (config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean),
); );
const lockdownAdminIds = new Set(
(config.admins || [])
.filter((admin) => admin.lockdown)
.map((admin) => String(admin.discord_id || '').trim())
.filter(Boolean),
);
if (!enabled) { if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token'); logger.info('Discord bot disabled; missing token in config.discord.token');
@@ -62,16 +46,13 @@ if (!enabled) {
const intents = [ const intents = [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildMessageTyping, GatewayIntentBits.GuildMessageTyping,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
]; ];
const client = new Client({ const client = new Client({
intents, intents,
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User], partials: [Partials.Channel],
}); });
const channelCache = new Map(); const channelCache = new Map();
@@ -80,8 +61,6 @@ let skippedFirstModeAnnouncement = false;
const PRESENCE_ROTATE_MS = 20000; const PRESENCE_ROTATE_MS = 20000;
let presenceInterval = null; let presenceInterval = null;
let presenceShowGoal = false; let presenceShowGoal = false;
const VERIFY_APPROVE_EMOJI = '✅';
const VERIFY_DENY_EMOJI = '❌';
function sanitizeMentions(text) { function sanitizeMentions(text) {
if (!text) return ''; if (!text) return '';
return String(text) return String(text)
@@ -159,7 +138,6 @@ function isCharging(sensors) {
function buildRoverStatusSnapshot(record) { function buildRoverStatusSnapshot(record) {
if (!record) return null; if (!record) return null;
if (!roverManager.canReplayRoverId(record.id)) return null;
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null; const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || null;
const docked = Boolean(sensors?.chargingSources?.homeBase); const docked = Boolean(sensors?.chargingSources?.homeBase);
const charging = isCharging(sensors); const charging = isCharging(sensors);
@@ -181,7 +159,7 @@ function buildRoverStatusSnapshot(record) {
} }
function countReady() { function countReady() {
const roster = getRoster().filter((entry) => roverManager.canReplayRoverId(entry.id)); const roster = getRoster();
const total = roster.length; const total = roster.length;
const ready = roster.filter((r) => !r.locked).length; const ready = roster.filter((r) => !r.locked).length;
return { ready, total }; return { ready, total };
@@ -267,10 +245,6 @@ function isAdminUser(discordId) {
return adminIds.has(String(discordId || '').trim()); return adminIds.has(String(discordId || '').trim());
} }
function isLockdownAdminUser(discordId) {
return lockdownAdminIds.has(String(discordId || '').trim());
}
function formatHelp() { function formatHelp() {
return [ return [
'**Rover Bot Commands**', '**Rover Bot Commands**',
@@ -284,14 +258,139 @@ function formatHelp() {
'`rs lock <id>` — lock a rover', '`rs lock <id>` — lock a rover',
'`rs unlock <id>` — unlock a rover', '`rs unlock <id>` — unlock a rover',
'`rs mode <open|turns|admin|lockdown>` — change server mode', '`rs mode <open|turns|admin|lockdown>` — change server mode',
'`rs reason [text|clear]` — show or set admin mode reason',
'`rs goal [text|clear]` — show or set community goal', '`rs goal [text|clear]` — show or set community goal',
'`rs verify list` — list verified users (lockdown admins)', '`rs ban <id> [reason]` — ban a user',
'`rs verify remove <cookieUserId|nickname>` — remove verified user (lockdown admins)', '`rs timeout <id> <duration> [reason]` — timeout a user',
'`rs unban <id>` — remove a ban/timeout',
'`rs users` — list recent users',
'`rs bans` — list active bans/timeouts',
'`ts` — show time status', '`ts` — show time status',
].join('\n'); ].join('\n');
} }
function parseDuration(input) {
if (!input) return null;
const match = String(input).trim().match(/^(\d+)(s|m|h|d)?$/i);
if (!match) return null;
const value = Number(match[1]);
const unit = (match[2] || 'm').toLowerCase();
const multipliers = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
const ms = value * (multipliers[unit] || 0);
return Number.isFinite(ms) && ms > 0 ? ms : null;
}
function formatModerationDuration(ms) {
if (!ms) return 'permanent';
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.ceil(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.ceil(hours / 24);
return `${days}d`;
}
async function handleBanCommand(message, tokens) {
const target = tokens.shift();
if (!target) {
await message.reply('Usage: `rs ban <id> [reason]`');
return;
}
const reason = tokens.join(' ').trim() || null;
try {
const ban = applyBan(target, {
reason,
createdBy: `discord:${message.author.id}`,
});
await message.reply(
`Banned **${sanitizeMentions(target)}**${reason ? `${sanitizeMentions(reason)}` : ''} (id: ${ban.id}).`,
);
} catch (err) {
await message.reply(`Ban failed: ${sanitizeMentions(err.message)}`);
}
}
async function handleTimeoutCommand(message, tokens) {
const target = tokens.shift();
const durationRaw = tokens.shift();
if (!target || !durationRaw) {
await message.reply('Usage: `rs timeout <id> <duration> [reason]`');
return;
}
const durationMs = parseDuration(durationRaw);
if (!durationMs) {
await message.reply('Invalid duration. Use formats like `30m`, `2h`, or `1d`.');
return;
}
const reason = tokens.join(' ').trim() || null;
try {
const ban = applyBan(target, {
durationMs,
reason,
createdBy: `discord:${message.author.id}`,
});
await message.reply(
`Timed out **${sanitizeMentions(target)}** for ${formatModerationDuration(durationMs)}${reason ? `${sanitizeMentions(reason)}` : ''} (id: ${ban.id}).`,
);
} catch (err) {
await message.reply(`Timeout failed: ${sanitizeMentions(err.message)}`);
}
}
async function handleUnbanCommand(message, tokens) {
const target = tokens.shift();
if (!target) {
await message.reply('Usage: `rs unban <id>`');
return;
}
try {
const removed = applyUnban(target);
if (removed) {
await message.reply(`Unbanned **${sanitizeMentions(target)}**.`);
} else {
await message.reply(`No ban found for **${sanitizeMentions(target)}**.`);
}
} catch (err) {
await message.reply(`Unban failed: ${sanitizeMentions(err.message)}`);
}
}
async function handleUsersCommand(message) {
const snapshot = getModerationSnapshot();
const users = snapshot.users || [];
const recent = users
.filter((user) => user.lastSeen)
.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
.slice(0, 10);
if (!recent.length) {
await message.reply('No recent users.');
return;
}
const lines = recent.map((user) => {
const name = user.nicknames?.[user.nicknames.length - 1] || user.id.slice(0, 6);
const status = user.ban ? 'BANNED' : 'ok';
return `${sanitizeMentions(name)} (${user.id.slice(0, 6)}) — ${status}`;
});
await message.reply(lines.join('\n'));
}
async function handleBansCommand(message) {
const snapshot = getModerationSnapshot();
const bans = snapshot.bans || [];
if (!bans.length) {
await message.reply('No active bans/timeouts.');
return;
}
const lines = bans.slice(0, 10).map((ban) => {
const expiresIn = ban.expiresAt ? Math.max(0, ban.expiresAt - Date.now()) : null;
return `${ban.id.slice(0, 6)} ${ban.userId ? `user ${ban.userId.slice(0, 6)}` : 'target'}${
expiresIn ? `timeout ${formatModerationDuration(expiresIn)}` : 'ban'
}`;
});
await message.reply(lines.join('\n'));
}
function findRoverRecord(id) { function findRoverRecord(id) {
if (!id) return null; if (!id) return null;
for (const record of rovers.values()) { for (const record of rovers.values()) {
@@ -467,9 +566,8 @@ async function handleLockCommand(message, roverId, locked) {
} }
} }
async function handleModeCommand(message, tokens = []) { async function handleModeCommand(message, mode) {
const next = String(tokens.shift() || '').toLowerCase(); const next = String(mode || '').toLowerCase();
const reasonText = tokens.join(' ').trim();
if (!Object.values(MODES).includes(next)) { if (!Object.values(MODES).includes(next)) {
await message.reply({ await message.reply({
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.', content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
@@ -478,11 +576,7 @@ async function handleModeCommand(message, tokens = []) {
return; return;
} }
try { try {
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin'; setMode(next, null, { force: true });
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
if (reasonText) {
setAdminReason(reasonText, { by: message.author?.id || null });
}
await message.reply({ await message.reply({
content: sanitizeMentions(`Mode set to ${next}.`), content: sanitizeMentions(`Mode set to ${next}.`),
allowedMentions: { parse: [], repliedUser: false }, allowedMentions: { parse: [], repliedUser: false },
@@ -495,57 +589,6 @@ async function handleModeCommand(message, tokens = []) {
} }
} }
async function handleReasonCommand(message, tokens) {
const query = tokens.join(' ').trim();
const lower = query.toLowerCase();
if (!query) {
const reason = getAdminReason();
const text = reason?.text ? reason.text : null;
await message.reply({
content: text ? `Admin mode reason: ${sanitizeMentions(text)}` : 'No admin mode reason set.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (!isAdminUser(message.author.id)) {
await message.reply({
content: 'Only admins can update the admin mode reason.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (lower === 'clear') {
try {
clearAdminReason({ by: message.author?.id || null });
await message.reply({
content: 'Admin mode reason cleared.',
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to clear reason: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
try {
setAdminReason(query, { by: message.author?.id || null });
await message.reply({
content: sanitizeMentions(`Admin mode reason set: ${query}`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to set reason: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
}
async function handleGoalCommand(message, tokens) { async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim(); const query = tokens.join(' ').trim();
const lower = query.toLowerCase(); const lower = query.toLowerCase();
@@ -597,74 +640,6 @@ async function handleGoalCommand(message, tokens) {
} }
} }
function formatMaskedCookieKey(value) {
const key = String(value || '').trim();
if (!key) return 'n/a';
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
return `${key.slice(0, 6)}...${key.slice(-6)}`;
}
async function handleVerifyCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
await message.reply({
content: 'Only lockdown admins can manage verified users.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') {
const users = listVerifiedUsers();
if (!users.length) {
await message.reply({
content: 'No verified users.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
const lines = users.map((entry, idx) => {
const updated = entry.updatedAt ? new Date(entry.updatedAt).toLocaleString() : 'unknown';
const ipCount = Array.isArray(entry.knownIps) ? entry.knownIps.length : 0;
return `${idx + 1}. ${entry.nickname || 'unknown'} | ${formatMaskedCookieKey(entry.cookieUserId)} | ips:${ipCount} | updated:${updated}`;
});
await message.reply({
content: ['Verified users:', ...lines].join('\n').slice(0, 1900),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action === 'remove') {
const selector = tokens.join(' ').trim();
if (!selector) {
await message.reply({
content: 'Usage: `rs verify remove <cookieUserId|nickname>`',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
const removed = removeVerifiedUser(selector, message.author?.id || null);
await message.reply({
content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${formatMaskedCookieKey(removed.cookieUserId)}).`,
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to remove verified user: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
await message.reply({
content: 'Unknown verify command. Use `rs verify list` or `rs verify remove <cookieUserId|nickname>`.',
allowedMentions: { parse: [], repliedUser: false },
});
}
function canManageBridge(message) { function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true; if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false; if (!message.guild || !message.member) return false;
@@ -839,10 +814,7 @@ async function handleCommand(message) {
tokens.shift(); // remove prefix tokens.shift(); // remove prefix
const action = (tokens.shift() || '').toLowerCase(); const action = (tokens.shift() || '').toLowerCase();
const isAdmin = isAdminUser(message.author.id); const isAdmin = isAdminUser(message.author.id);
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false; const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
const mode = getMode();
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify']);
if ( if (
!isAdmin && !isAdmin &&
@@ -853,20 +825,15 @@ async function handleCommand(message) {
action !== 'replay' && action !== 'replay' &&
action !== 'bridge' && action !== 'bridge' &&
action !== 'goal' && action !== 'goal' &&
action !== 'reason' && action !== 'ban' &&
action !== 'verify' action !== 'timeout' &&
action !== 'unban' &&
action !== 'users' &&
action !== 'bans'
) { ) {
return; // ignore non-admins for privileged commands return; // ignore non-admins for privileged commands
} }
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await message.reply({
content: 'Lockdown mode: only lockdown admins can run that command.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
switch (action) { switch (action) {
case '': case '':
await handleStatusCommand(message, tokens[0]); await handleStatusCommand(message, tokens[0]);
@@ -890,16 +857,25 @@ async function handleCommand(message) {
await handleLockCommand(message, tokens[0], false); await handleLockCommand(message, tokens[0], false);
break; break;
case 'mode': case 'mode':
await handleModeCommand(message, tokens); await handleModeCommand(message, tokens[0]);
break; break;
case 'goal': case 'goal':
await handleGoalCommand(message, tokens); await handleGoalCommand(message, tokens);
break; break;
case 'reason': case 'ban':
await handleReasonCommand(message, tokens); await handleBanCommand(message, tokens);
break; break;
case 'verify': case 'timeout':
await handleVerifyCommand(message, tokens); await handleTimeoutCommand(message, tokens);
break;
case 'unban':
await handleUnbanCommand(message, tokens);
break;
case 'users':
await handleUsersCommand(message);
break;
case 'bans':
await handleBansCommand(message);
break; break;
default: default:
await message.reply(formatHelp()); await message.reply(formatHelp());
@@ -974,7 +950,7 @@ async function sendTypingMessage(entry, payload) {
const username = formatWebhookUsername(payload); const username = formatWebhookUsername(payload);
const content = `-# *${username} is typing...*`; const content = `-# *${username} is typing...*`;
try { try {
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]}); const message = await channel.send({ content, allowedMentions: { parse: [] } });
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
clearTypingMessage(entry.guildId, typingId); clearTypingMessage(entry.guildId, typingId);
}, 20000); }, 20000);
@@ -1099,9 +1075,7 @@ async function handleTimeStatusCommand(message) {
function buildBatteryStatusEmbed(color, records = null) { function buildBatteryStatusEmbed(color, records = null) {
const embed = buildEmbed({ title: 'Rover Battery Status', color: color || 0x2196f3 }); const embed = buildEmbed({ title: 'Rover Battery Status', color: color || 0x2196f3 });
const baseRecords = (records || Array.from(rovers.values())).filter((entry) => const baseRecords = records || Array.from(rovers.values());
roverManager.canReplayRoverId(entry?.id),
);
const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean); const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
if (snapshots.length === 0) { if (snapshots.length === 0) {
embed.setDescription('No rovers online.'); embed.setDescription('No rovers online.');
@@ -1139,9 +1113,7 @@ function buildBatteryStatusEmbed(color, records = null) {
function buildAllUnlockedEmbed(color, records = null) { function buildAllUnlockedEmbed(color, records = null) {
const embed = buildEmbed({ title: 'All Rovers Unlocked', color: color || 0x4caf50 }); const embed = buildEmbed({ title: 'All Rovers Unlocked', color: color || 0x4caf50 });
const baseRecords = (records || Array.from(rovers.values())).filter((entry) => const baseRecords = records || Array.from(rovers.values());
roverManager.canReplayRoverId(entry?.id),
);
const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean); const snapshots = baseRecords.map(buildRoverStatusSnapshot).filter(Boolean);
if (snapshots.length === 0) { if (snapshots.length === 0) {
embed.setDescription('No rovers online.'); embed.setDescription('No rovers online.');
@@ -1164,9 +1136,8 @@ function buildAllUnlockedCaption(records = null) {
} }
function buildAccessModeEmbed(mode, color) { function buildAccessModeEmbed(mode, color) {
const visible = Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id)); const total = rovers.size;
const total = visible.length; const unlocked = Array.from(rovers.values()).filter((entry) => !entry.locked).length;
const unlocked = visible.filter((entry) => !entry.locked).length;
const embed = buildEmbed({ const embed = buildEmbed({
title: 'Access Mode Updated', title: 'Access Mode Updated',
description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`, description: `Access mode set to **${mode}**\nUnlocked rovers: **${unlocked}/${total}**`,
@@ -1177,9 +1148,6 @@ function buildAccessModeEmbed(mode, color) {
function buildBatteryCaption(type, payload) { function buildBatteryCaption(type, payload) {
const roverId = payload?.roverId || 'unknown'; const roverId = payload?.roverId || 'unknown';
if (!roverManager.canReplayRoverId(roverId)) {
return null;
}
const record = rovers.get(roverId) || findRoverRecord(roverId); const record = rovers.get(roverId) || findRoverRecord(roverId);
const snapshot = buildRoverStatusSnapshot(record); const snapshot = buildRoverStatusSnapshot(record);
const base = snapshot?.name || roverId; const base = snapshot?.name || roverId;
@@ -1268,10 +1236,6 @@ function handleBusEvent(event) {
const { type, payload } = event || {}; const { type, payload } = event || {};
const channels = discordConfig.channels || {}; const channels = discordConfig.channels || {};
const roles = discordConfig.roles || {}; const roles = discordConfig.roles || {};
const roverId = payload?.roverId || null;
if (roverId && !roverManager.canReplayRoverId(roverId)) {
return;
}
switch (type) { switch (type) {
case 'mode.changed': case 'mode.changed':
if (!skippedFirstModeAnnouncement) { if (!skippedFirstModeAnnouncement) {
@@ -1356,7 +1320,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.warn': case 'battery.warn':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null, pingRoleId: roles.adminPing || null,
@@ -1368,7 +1331,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.urgent': case 'battery.urgent':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
pingRoleId: roles.adminPing || null, pingRoleId: roles.adminPing || null,
@@ -1380,7 +1342,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.docked': case 'battery.docked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x2196f3, color: 0x2196f3,
@@ -1391,7 +1352,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.undocked': case 'battery.undocked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x2196f3, color: 0x2196f3,
@@ -1402,7 +1362,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.charging.start': case 'battery.charging.start':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x2196f3, color: 0x2196f3,
@@ -1413,7 +1372,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.charging.stop': case 'battery.charging.stop':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0xf0b651, color: 0xf0b651,
@@ -1424,7 +1382,6 @@ function handleBusEvent(event) {
}); });
break; break;
case 'battery.locked': case 'battery.locked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0xf0b651, color: 0xf0b651,
@@ -1436,7 +1393,6 @@ function handleBusEvent(event) {
schedulePresenceRotation(); schedulePresenceRotation();
break; break;
case 'battery.unlocked': case 'battery.unlocked':
if (!buildBatteryCaption(type, payload)) break;
announce({ announce({
channelId: channels.adminAlerts, channelId: channels.adminAlerts,
color: 0x4caf50, color: 0x4caf50,
@@ -1457,100 +1413,9 @@ function handleBusEvent(event) {
} }
} }
async function sendVerificationRequestDms(event) {
const payload = event?.payload || {};
const requestId = payload.id;
if (!requestId) return;
const adminIdsToNotify = Array.from(lockdownAdminIds);
if (!adminIdsToNotify.length) {
logger.warn('No lockdown admins configured for verification request DM', { requestId });
return;
}
const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown';
const content = [
'**Verification Request**',
`Request ID: \`${requestId}\``,
`Nickname: ${sanitizeMentions(payload.nickname || 'unknown')}`,
`Identity key: \`${payload.cookieUserId || 'unknown'}\``,
`IP: \`${payload.ip || 'unknown'}\``,
`Created: ${createdAt}`,
'',
`React with ${VERIFY_APPROVE_EMOJI} to approve or ${VERIFY_DENY_EMOJI} to deny.`,
].join('\n');
await Promise.all(
adminIdsToNotify.map(async (adminId) => {
try {
const user = await client.users.fetch(String(adminId));
if (!user) return;
const dm = await user.createDM();
const message = await dm.send({ content, allowedMentions: { parse: [] } });
try {
await message.react(VERIFY_APPROVE_EMOJI);
await message.react(VERIFY_DENY_EMOJI);
} catch (err) {
logger.warn('Failed to add verification reactions', { requestId, adminId, error: err.message });
}
attachDmMessage(requestId, message.id, adminId);
} catch (err) {
logger.warn('Failed to DM lockdown admin for verification request', {
requestId,
adminId,
error: err.message,
});
}
}),
);
}
async function handleVerificationReaction(reaction, user) {
if (!reaction || !user || user.bot) return;
const emoji = reaction.emoji?.name;
if (emoji !== VERIFY_APPROVE_EMOJI && emoji !== VERIFY_DENY_EMOJI) return;
if (!isLockdownAdminUser(user.id)) return;
const maybePartial = reaction.message?.partial || reaction.partial;
if (maybePartial) {
try {
await reaction.fetch();
} catch (err) {
logger.warn('Failed to fetch partial reaction', err.message);
return;
}
}
const messageId = reaction.message?.id;
if (!messageId) return;
const linked = getRequestByMessageId(messageId);
if (!linked?.request || linked.request.status !== 'pending') return;
try {
if (emoji === VERIFY_APPROVE_EMOJI) {
approveRequest(linked.request.id, user.id);
await reaction.message.reply({
content: `Approved request \`${linked.request.id}\`.`,
allowedMentions: { parse: [] },
});
} else {
denyRequest(linked.request.id, user.id);
await reaction.message.reply({
content: `Denied request \`${linked.request.id}\`.`,
allowedMentions: { parse: [] },
});
}
} catch (err) {
logger.warn('Failed to resolve verification request from reaction', {
requestId: linked.request.id,
error: err.message,
});
}
}
function handleChatBridgeOutbound(event) { function handleChatBridgeOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload) return; if (!payload) return;
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
const guildConfigs = listGuildConfigs(); const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return; if (!guildConfigs.length) return;
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text; const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
@@ -1589,7 +1454,6 @@ function handleChatBridgeOutbound(event) {
function handleChatTypingOutbound(event) { function handleChatTypingOutbound(event) {
const payload = event?.payload; const payload = event?.payload;
if (!payload || payload.fromDiscord) return; if (!payload || payload.fromDiscord) return;
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
const guildConfigs = listGuildConfigs(); const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return; if (!guildConfigs.length) return;
guildConfigs.forEach((entry) => { guildConfigs.forEach((entry) => {
@@ -1646,19 +1510,12 @@ client.on('typingStart', (typing) => {
}); });
}); });
client.on('messageReactionAdd', (reaction, user) => {
handleVerificationReaction(reaction, user).catch((err) => {
logger.warn('Error handling verification reaction', err.message);
});
});
client.once('ready', () => { client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag }); logger.info('Discord bot logged in', { tag: client.user?.tag });
schedulePresenceRotation(); schedulePresenceRotation();
}); });
subscribe('*', handleBusEvent); subscribe('*', handleBusEvent);
subscribe('verification.requested', sendVerificationRequestDms);
subscribe('chat:message', handleChatBridgeOutbound); subscribe('chat:message', handleChatBridgeOutbound);
subscribe('chat:typing', handleChatTypingOutbound); subscribe('chat:typing', handleChatTypingOutbound);
+3 -13
View File
@@ -62,19 +62,9 @@ function sumQueueCounts(turnQueues = {}) {
}, 0); }, 0);
} }
function getPublicRovers() {
return roverManager
.getRoster()
.filter((rover) => roverManager.canReplayRoverId(rover.id));
}
function buildEmbedCopy(state, camera) { function buildEmbedCopy(state, camera) {
const roversOnline = state?.rovers?.length || 0; const roversOnline = state?.rovers?.length || 0;
const visibleRoverIds = new Set((state?.rovers || []).map((rover) => String(rover.id))); const driverCount = Object.keys(state?.activeDrivers || {}).length;
const driverCount = Object.entries(state?.activeDrivers || {}).reduce((count, [roverId, socketId]) => {
if (!socketId) return count;
return visibleRoverIds.has(String(roverId)) ? count + 1 : count;
}, 0);
const mode = state?.mode || 'open'; const mode = state?.mode || 'open';
const modeLabel = { const modeLabel = {
open: 'open drive', open: 'open drive',
@@ -158,7 +148,7 @@ async function renderIndexHtml(req) {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const state = { const state = {
mode: getMode(), mode: getMode(),
rovers: getPublicRovers(), rovers: roverManager.getRoster(),
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
}; };
@@ -226,7 +216,7 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
async function renderOgImage() { async function renderOgImage() {
const state = { const state = {
mode: getMode(), mode: getMode(),
rovers: getPublicRovers(), rovers: roverManager.getRoster(),
activeDrivers: getActiveDrivers(), activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(), turnQueues: getTurnQueues(),
}; };
+3 -73
View File
@@ -5,7 +5,7 @@ const io = require('../globals/io');
const logger = require('../globals/logger').child('homeAssistantService'); const logger = require('../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getMode } = require('./modeManager'); const { getMode } = require('./modeManager');
const { isAdmin, isLockdownAdmin } = require('./roleService'); const { isAdmin } = require('./roleService');
// home-assistant-js-websocket expects a global WebSocket in Node. // home-assistant-js-websocket expects a global WebSocket in Node.
if (!global.WebSocket) { if (!global.WebSocket) {
@@ -81,16 +81,6 @@ function loadEntityConfig() {
function buildState(meta, raw) { function buildState(meta, raw) {
if (!meta) return null; if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id; const name = meta.name || raw?.attributes?.friendly_name || meta.id;
const supportedColorModes = Array.isArray(raw?.attributes?.supported_color_modes)
? raw.attributes.supported_color_modes.map((mode) => String(mode))
: [];
const rgbColor = Array.isArray(raw?.attributes?.rgb_color) ? raw.attributes.rgb_color : null;
const hsColor = Array.isArray(raw?.attributes?.hs_color) ? raw.attributes.hs_color : null;
const supportsColor =
meta.type === 'light' &&
(rgbColor ||
hsColor ||
supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
if (!raw) { if (!raw) {
return { return {
id: meta.id, id: meta.id,
@@ -100,11 +90,6 @@ function buildState(meta, raw) {
available: false, available: false,
lastChanged: null, lastChanged: null,
lastUpdated: null, lastUpdated: null,
supportedColorModes,
colorMode: null,
rgbColor: null,
hsColor: null,
supportsColor,
}; };
} }
const rawState = raw.state; const rawState = raw.state;
@@ -118,11 +103,6 @@ function buildState(meta, raw) {
available: !unavailable, available: !unavailable,
lastChanged: raw.last_changed || null, lastChanged: raw.last_changed || null,
lastUpdated: raw.last_updated || null, lastUpdated: raw.last_updated || null,
supportedColorModes,
colorMode: raw?.attributes?.color_mode || null,
rgbColor,
hsColor,
supportsColor,
}; };
} }
@@ -239,29 +219,6 @@ async function toggleEntity(entityId) {
return setEntityState(entityId, nextState); return setEntityState(entityId, nextState);
} }
async function setLightColor(entityId, rgbColor) {
if (!enabled) {
throw new Error('Home Assistant not configured');
}
const meta = entityConfig.get(entityId);
if (!meta || meta.type !== 'light') {
throw new Error('Home Assistant light required');
}
if (!connection) {
throw new Error('Home Assistant not connected');
}
if (!Array.isArray(rgbColor) || rgbColor.length !== 3) {
throw new Error('rgbColor required');
}
const normalized = rgbColor.map((value) => {
const next = Number(value);
if (Number.isNaN(next)) return 0;
return Math.max(0, Math.min(255, Math.round(next)));
});
await callService(connection, 'light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}
function getState() { function getState() {
const entities = Array.from(entityConfig.values()).map( const entities = Array.from(entityConfig.values()).map(
(meta) => entityState.get(meta.id) || buildState(meta, null), (meta) => entityState.get(meta.id) || buildState(meta, null),
@@ -274,11 +231,7 @@ connect();
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => { socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
const mode = getMode(); if ((getMode() === 'admin' || getMode() === 'lockdown') && isAdmin(socket) !== true) {
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
@@ -292,11 +245,7 @@ io.on('connection', (socket) => {
}); });
socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => { socket.on('homeAssistant:setState', async ({ entityId, state } = {}, cb = () => {}) => {
const mode = getMode(); if ((getMode() === 'admin' || getMode() === 'lockdown') && isAdmin(socket) !== true) {
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
@@ -308,30 +257,11 @@ io.on('connection', (socket) => {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
const mode = getMode();
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await setLightColor(entityId, rgbColor);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
}); });
module.exports = { module.exports = {
getState, getState,
toggleEntity, toggleEntity,
setEntityState, setEntityState,
setLightColor,
homeAssistantEvents: events, homeAssistantEvents: events,
}; };
File diff suppressed because it is too large Load Diff
+6
View File
@@ -22,6 +22,12 @@ function enforceLockdown() {
} }
} }
io.on('connection', (socket) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
disconnectForLockdown(socket);
}
});
module.exports = { module.exports = {
enforceLockdown, enforceLockdown,
disconnectForLockdown, disconnectForLockdown,
+7 -1
View File
@@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io'); const io = require('../globals/io');
const loggerRoot = require('../globals/logger'); const loggerRoot = require('../globals/logger');
const logger = loggerRoot.child('logStream'); const logger = loggerRoot.child('logStream');
const { isBannedSocket } = require('./moderationService');
const MAX_HISTORY = 200; const MAX_HISTORY = 200;
const history = []; const history = [];
@@ -14,11 +15,16 @@ function pushEntry(entry) {
} }
function broadcast(entry) { function broadcast(entry) {
io.emit('log:entry', entry); io.sockets.sockets.forEach((socket) => {
if (!isBannedSocket(socket)) {
socket.emit('log:entry', entry);
}
});
} }
function hydrateSocket(socket) { function hydrateSocket(socket) {
if (!socket) return; if (!socket) return;
if (isBannedSocket(socket)) return;
socket.emit('log:init', history); socket.emit('log:init', history);
} }
+1 -4
View File
@@ -16,11 +16,8 @@ let currentMode = MODES.ADMIN;
const modeEvents = new EventEmitter(); const modeEvents = new EventEmitter();
function canChangeMode(socket, nextMode) { function canChangeMode(socket, nextMode) {
if (currentMode === MODES.LOCKDOWN && nextMode !== MODES.LOCKDOWN) {
return isLockdownAdmin(socket);
}
if (nextMode === MODES.LOCKDOWN) { if (nextMode === MODES.LOCKDOWN) {
return isAdmin(socket); return isLockdownAdmin(socket);
} }
return isAdmin(socket); return isAdmin(socket);
} }
+587
View File
@@ -0,0 +1,587 @@
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const io = require('../globals/io');
const logger = require('../globals/logger').child('moderationService');
const { getRole, roleEvents } = require('./roleService');
const { getNickname, nicknameEvents } = require('./nicknameService');
const { getSocketIp } = require('../helpers/ipResolver');
const { parseCookieHeader } = require('../helpers/cookieParser');
const { logAdminEvent } = require('./adminLogService');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'moderation.json');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const VISITOR_COOKIE = 'roverd_visitor';
const EVENT_ALLOWLIST = new Set(['auth:login']);
const MAX_HISTORY_ENTRIES = 50;
let cache = null;
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load moderation store', err.message);
}
cache = { users: {}, bans: {}, history: [] };
}
if (!cache.users) cache.users = {};
if (!cache.bans) cache.bans = {};
if (!Array.isArray(cache.history)) cache.history = [];
return cache;
}
function isAdminRole(role) {
return ADMIN_ROLES.has(role);
}
function isAdminSocket(socket) {
return isAdminRole(getRole(socket));
}
function saveStore(next) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
cache = next;
}
function recordHistory(entry) {
const store = loadStore();
store.history.push(entry);
if (store.history.length > MAX_HISTORY_ENTRIES) {
store.history.shift();
}
}
function parseClientId(socket) {
const auth = socket.handshake?.auth || {};
const clientId =
auth.clientId ||
socket.handshake?.query?.clientId ||
socket.data?.clientId ||
null;
if (typeof clientId === 'string' && clientId.trim()) {
return clientId.trim();
}
return null;
}
function parseVisitorToken(socket) {
const cookies = parseCookieHeader(socket.handshake?.headers?.cookie || '');
const token = cookies[VISITOR_COOKIE];
if (typeof token === 'string' && token.trim()) {
return token.trim();
}
return null;
}
function buildIdentity(socket) {
if (!socket) return {};
return {
clientId: parseClientId(socket),
visitorToken: parseVisitorToken(socket),
ip: getSocketIp(socket),
};
}
function findUserByIdentity(identity) {
const store = loadStore();
const users = Object.values(store.users || {});
return users.find((user) => {
if (identity.clientId && user.clientId === identity.clientId) return true;
if (identity.visitorToken && user.visitorToken === identity.visitorToken) return true;
if (identity.ip && Array.isArray(user.ips) && user.ips.includes(identity.ip)) return true;
return false;
}) || null;
}
function findUserByQuery(query) {
if (!query) return null;
const store = loadStore();
const users = Object.values(store.users || {});
return users.find((user) => {
if (user.id === query) return true;
if (user.clientId === query) return true;
if (user.visitorToken === query) return true;
if (user.lastSocketId === query) return true;
if (Array.isArray(user.socketIds) && user.socketIds.includes(query)) return true;
if (Array.isArray(user.nicknames) && user.nicknames.includes(query)) return true;
if (Array.isArray(user.ips) && user.ips.includes(query)) return true;
return false;
}) || null;
}
function updateUserFromSocket(user, socket, identity) {
let changed = false;
const now = Date.now();
if (!user.firstSeen) {
user.firstSeen = now;
changed = true;
}
if (!user.lastSeen || now > user.lastSeen) {
user.lastSeen = now;
changed = true;
}
const role = getRole(socket);
if (user.lastRole !== role) {
user.lastRole = role;
changed = true;
}
const nickname = getNickname(socket) || null;
if (nickname) {
user.nicknames = Array.isArray(user.nicknames) ? user.nicknames : [];
if (!user.nicknames.includes(nickname)) {
user.nicknames.push(nickname);
changed = true;
}
}
if (identity.clientId && user.clientId !== identity.clientId) {
user.clientId = identity.clientId;
changed = true;
}
if (identity.visitorToken && user.visitorToken !== identity.visitorToken) {
user.visitorToken = identity.visitorToken;
changed = true;
}
if (identity.ip) {
user.ips = Array.isArray(user.ips) ? user.ips : [];
if (!user.ips.includes(identity.ip)) {
user.ips.push(identity.ip);
changed = true;
}
user.lastIp = identity.ip;
}
if (user.lastSocketId !== socket.id) {
user.lastSocketId = socket.id;
changed = true;
}
user.socketIds = Array.isArray(user.socketIds) ? user.socketIds : [];
if (!user.socketIds.includes(socket.id)) {
user.socketIds.push(socket.id);
changed = true;
}
return changed;
}
function ensureUserForSocket(socket) {
const store = loadStore();
const identity = buildIdentity(socket);
let user = findUserByIdentity(identity);
if (!user) {
user = {
id: uuidv4(),
clientId: identity.clientId || null,
visitorToken: identity.visitorToken || null,
ips: identity.ip ? [identity.ip] : [],
nicknames: [],
socketIds: [],
firstSeen: null,
lastSeen: null,
lastRole: null,
lastSocketId: null,
lastIp: identity.ip || null,
};
store.users[user.id] = user;
}
const changed = updateUserFromSocket(user, socket, identity);
socket.data.moderation = { userId: user.id, ...identity };
if (changed) {
saveStore(store);
emitModerationSnapshot();
}
return user;
}
function cleanupExpiredBans() {
const store = loadStore();
const now = Date.now();
let changed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.expiresAt && ban.expiresAt <= now) {
delete store.bans[ban.id];
changed = true;
}
});
if (changed) {
saveStore(store);
}
return changed;
}
function banMatchesIdentity(ban, identity) {
if (!ban) return false;
if (ban.userId && identity.userId && ban.userId === identity.userId) return true;
if (ban.clientId && identity.clientId && ban.clientId === identity.clientId) return true;
if (ban.visitorToken && identity.visitorToken && ban.visitorToken === identity.visitorToken) return true;
if (ban.ip && identity.ip && ban.ip === identity.ip) return true;
return false;
}
function findActiveBan(identity) {
cleanupExpiredBans();
const store = loadStore();
const bans = Object.values(store.bans || {});
return (
bans.find((ban) => {
if (ban.expiresAt && ban.expiresAt <= Date.now()) return false;
return banMatchesIdentity(ban, identity);
}) || null
);
}
function isBannedSocket(socket) {
if (!socket || isAdminSocket(socket)) return false;
const identity = socket.data?.moderation || buildIdentity(socket);
identity.userId = identity.userId || socket.data?.moderation?.userId || null;
return Boolean(findActiveBan(identity));
}
function refreshSocketStatus(socket) {
if (!socket) return;
if (isAdminSocket(socket)) {
if (socket.data?.banInfo) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
}
return;
}
const identity = { ...(socket.data?.moderation || buildIdentity(socket)) };
const ban = findActiveBan(identity);
const prevId = socket.data?.banInfo?.id || null;
if (!ban && prevId) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
return;
}
if (!ban) {
socket.data.banInfo = null;
socket.emit('moderation:status', { banned: false });
return;
}
if (prevId !== ban.id) {
socket.data.banInfo = ban;
socket.emit('moderation:status', {
banned: true,
reason: ban.reason || null,
expiresAt: ban.expiresAt || null,
createdAt: ban.createdAt || null,
});
}
}
function serializeUser(user) {
const activeBan = findActiveBan({ userId: user.id, clientId: user.clientId, visitorToken: user.visitorToken, ip: user.lastIp });
return {
id: user.id,
clientId: user.clientId || null,
visitorToken: user.visitorToken || null,
ips: user.ips || [],
nicknames: user.nicknames || [],
lastSeen: user.lastSeen || null,
firstSeen: user.firstSeen || null,
lastRole: user.lastRole || null,
lastSocketId: user.lastSocketId || null,
ban: activeBan
? {
id: activeBan.id,
reason: activeBan.reason || null,
createdAt: activeBan.createdAt || null,
expiresAt: activeBan.expiresAt || null,
createdBy: activeBan.createdBy || null,
}
: null,
};
}
function getModerationSnapshot() {
cleanupExpiredBans();
const store = loadStore();
return {
users: Object.values(store.users || {}).map(serializeUser),
bans: Object.values(store.bans || {}),
};
}
function emitModerationSnapshot() {
const payload = getModerationSnapshot();
io.sockets.sockets.forEach((socket) => {
if (!isAdminSocket(socket)) return;
socket.emit('moderation:update', payload);
});
}
function clearBansForUser(user, meta = {}) {
if (!user) return false;
const store = loadStore();
let changed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.userId === user.id) {
delete store.bans[ban.id];
changed = true;
}
if (user.clientId && ban.clientId === user.clientId) {
delete store.bans[ban.id];
changed = true;
}
if (user.visitorToken && ban.visitorToken === user.visitorToken) {
delete store.bans[ban.id];
changed = true;
}
if (user.lastIp && ban.ip === user.lastIp) {
delete store.bans[ban.id];
changed = true;
}
});
if (changed) {
recordHistory({
id: uuidv4(),
action: 'unban',
createdAt: Date.now(),
createdBy: meta.by || null,
reason: meta.reason || null,
userId: user.id,
});
saveStore(store);
}
return changed;
}
function resolveTarget(target = {}) {
if (typeof target === 'string') {
const query = target.trim();
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(query)) {
return { ip: query, query };
}
return { query };
}
return target;
}
function createBan(target, { durationMs = null, reason = null, createdBy = null } = {}) {
cleanupExpiredBans();
const store = loadStore();
const resolved = resolveTarget(target);
const query = resolved.query || null;
const user =
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
const targetSocketId = resolved.socketId || user?.lastSocketId || null;
if (targetSocketId) {
const targetSocket = io.sockets.sockets.get(targetSocketId);
if (targetSocket && isAdminSocket(targetSocket)) {
throw new Error('Active admins cannot be banned.');
}
}
const identity = {
userId: user?.id || null,
clientId: resolved.clientId || user?.clientId || null,
visitorToken: resolved.visitorToken || user?.visitorToken || null,
ip: resolved.ip || user?.lastIp || null,
};
if (!identity.userId && !identity.clientId && !identity.visitorToken && !identity.ip) {
throw new Error('Unknown user.');
}
const cleanReason = typeof reason === 'string' ? reason.trim() : null;
const safeDuration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : null;
const ban = {
id: uuidv4(),
userId: identity.userId,
clientId: identity.clientId,
visitorToken: identity.visitorToken,
ip: identity.ip,
reason: cleanReason || null,
createdAt: Date.now(),
expiresAt: safeDuration ? Date.now() + safeDuration : null,
createdBy: createdBy || null,
};
Object.values(store.bans || {}).forEach((existing) => {
if (banMatchesIdentity(existing, identity)) {
delete store.bans[existing.id];
}
});
store.bans[ban.id] = ban;
recordHistory({
id: uuidv4(),
action: durationMs ? 'timeout' : 'ban',
createdAt: ban.createdAt,
createdBy: ban.createdBy,
reason: ban.reason,
userId: ban.userId || null,
banId: ban.id,
expiresAt: ban.expiresAt,
});
saveStore(store);
return ban;
}
function removeBan(target) {
cleanupExpiredBans();
const store = loadStore();
const resolved = resolveTarget(target);
const banId = resolved.banId || resolved.query;
if (banId && store.bans[banId]) {
delete store.bans[banId];
saveStore(store);
return true;
}
const query = resolved.query || null;
const user =
resolved.userId ? store.users[resolved.userId] || null : findUserByQuery(query || resolved.socketId || resolved.nickname || resolved.clientId || resolved.visitorToken || resolved.ip);
if (user) {
const changed = clearBansForUser(user, { by: resolved.by || null, reason: resolved.reason || null });
if (changed) {
saveStore(store);
}
return changed;
}
if (resolved.ip) {
let removed = false;
Object.values(store.bans || {}).forEach((ban) => {
if (ban.ip === resolved.ip) {
delete store.bans[ban.id];
removed = true;
}
});
if (removed) {
saveStore(store);
}
return removed;
}
return false;
}
function applyBan(target, options) {
const ban = createBan(target, options);
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
return ban;
}
function applyUnban(target) {
const removed = removeBan(target);
if (removed) {
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
}
return removed;
}
function registerSocket(socket) {
const user = ensureUserForSocket(socket);
refreshSocketStatus(socket);
socket.use((packet, next) => {
if (!packet || !packet.length) return next();
const event = packet[0];
if (EVENT_ALLOWLIST.has(event)) return next();
if (isAdminSocket(socket)) return next();
if (isBannedSocket(socket)) {
return next(new Error('banned'));
}
return next();
});
socket.on('disconnect', () => {
const store = loadStore();
if (!store.users[user.id]) return;
store.users[user.id].lastSeen = Date.now();
saveStore(store);
});
}
io.on('connection', (socket) => {
registerSocket(socket);
if (isAdminSocket(socket)) {
socket.emit('moderation:init', getModerationSnapshot());
}
socket.on('moderation:ban', ({ target, durationMs, reason } = {}, cb = () => {}) => {
if (!isAdminSocket(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const ban = applyBan(target, {
durationMs: durationMs || null,
reason,
createdBy: socket?.data?.user?.username || socket.id,
});
logAdminEvent({
label: 'moderation',
message: durationMs ? 'User timed out' : 'User banned',
ip: socket?.data?.moderation?.ip || null,
meta: { target, reason, expiresAt: ban.expiresAt || null },
socketId: socket.id,
});
cb({ success: true, ban });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('moderation:unban', ({ target } = {}, cb = () => {}) => {
if (!isAdminSocket(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const removed = applyUnban({ ...target, by: socket?.data?.user?.username || socket.id });
if (removed) {
logAdminEvent({
label: 'moderation',
message: 'User unbanned',
ip: socket?.data?.moderation?.ip || null,
meta: { target },
socketId: socket.id,
});
}
cb({ success: true, removed });
} catch (err) {
cb({ error: err.message });
}
});
});
roleEvents.on('change', ({ socket, role }) => {
if (!socket) return;
if (ADMIN_ROLES.has(role)) {
ensureUserForSocket(socket);
refreshSocketStatus(socket);
socket.emit('moderation:init', getModerationSnapshot());
}
});
nicknameEvents.on('change', ({ socketId }) => {
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
if (socket) {
const store = loadStore();
const identity = buildIdentity(socket);
const user = findUserByIdentity(identity);
if (user) {
const changed = updateUserFromSocket(user, socket, identity);
if (changed) {
saveStore(store);
emitModerationSnapshot();
}
}
}
});
setInterval(() => {
const expired = cleanupExpiredBans();
if (expired) {
emitModerationSnapshot();
io.sockets.sockets.forEach((socket) => refreshSocketStatus(socket));
}
}, 30 * 1000);
module.exports = {
buildIdentity,
getModerationSnapshot,
isBannedSocket,
findUserByQuery,
createBan,
removeBan,
applyBan,
applyUnban,
refreshSocketStatus,
};
+5 -3
View File
@@ -1,6 +1,7 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const io = require('../globals/io'); const io = require('../globals/io');
const logger = require('../globals/logger').child('nicknameService'); const logger = require('../globals/logger').child('nicknameService');
const { getRole } = require('./roleService');
const nicknameEvents = new EventEmitter(); const nicknameEvents = new EventEmitter();
@@ -18,14 +19,15 @@ function getNickname(socket) {
function setNickname(socket, nickname) { function setNickname(socket, nickname) {
if (!socket) return null; if (!socket) return null;
const role = getRole(socket);
// if (role === 'spectator') {
// throw new Error('Spectators cannot set nicknames');
// }
const value = sanitizeNickname(nickname); const value = sanitizeNickname(nickname);
if (!value) { if (!value) {
throw new Error('Nickname required'); throw new Error('Nickname required');
} }
socket.data = socket.data || {}; socket.data = socket.data || {};
if (socket.data.nickname === value) {
return value;
}
socket.data.nickname = value; socket.data.nickname = value;
nicknameEvents.emit('change', { socketId: socket.id, nickname: value }); nicknameEvents.emit('change', { socketId: socket.id, nickname: value });
logger.info('Nickname set', { socketId: socket.id, nickname: value }); logger.info('Nickname set', { socketId: socket.id, nickname: value });
+1 -22
View File
@@ -210,26 +210,11 @@ function stopRecorder(key) {
recorders.delete(key); recorders.delete(key);
} }
async function removeSourceArtifacts(key, source) {
try {
await fsp.rm(path.join(SEGMENT_DIR, key), { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to clear replay segments', { key, error: err.message });
}
if (source?.type === 'rover' && source?.id) {
try {
await fsp.rm(path.join(ROVER_SNAPSHOT_DIR, `${source.id}.jpg`), { force: true });
} catch (err) {
logger.warn('Failed to clear rover snapshot artifact', { roverId: source.id, error: err.message });
}
}
}
function shouldRecord(source) { function shouldRecord(source) {
if (source.type === 'room') { if (source.type === 'room') {
return Boolean(source.streamUrl); return Boolean(source.streamUrl);
} }
return roverManager.canReplayRoverId(source.id); return true;
} }
function syncRecorders() { function syncRecorders() {
@@ -245,9 +230,7 @@ function syncRecorders() {
}); });
Array.from(recorders.keys()).forEach((key) => { Array.from(recorders.keys()).forEach((key) => {
if (!desiredKeys.has(key)) { if (!desiredKeys.has(key)) {
const entry = recorders.get(key);
stopRecorder(key); stopRecorder(key);
removeSourceArtifacts(key, entry?.source).catch(() => {});
} }
}); });
} }
@@ -312,10 +295,6 @@ roverManager.managerEvents.on('rover', () => {
syncRecorders(); syncRecorders();
}); });
roverManager.managerEvents.on('private', () => {
syncRecorders();
});
start(); start();
module.exports = { module.exports = {
+2 -2
View File
@@ -27,10 +27,10 @@ io.on('connection', (socket) => {
return; return;
} }
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null; const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
let sources = requestedSources ? validateSources(requestedSources, socket) : []; let sources = requestedSources ? validateSources(requestedSources) : [];
if (!sources.length) { if (!sources.length) {
const assignment = assignmentService.describeAssignment(socket.id); const assignment = assignmentService.describeAssignment(socket.id);
sources = getDefaultWebSources(assignment, socket); sources = getDefaultWebSources(assignment);
} }
if (!sources.length) { if (!sources.length) {
cb({ error: 'No replay sources selected', state: null }); cb({ error: 'No replay sources selected', state: null });
+11 -16
View File
@@ -1,16 +1,12 @@
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { getRoomCameras } = require('./roomCameraService'); const { getRoomCameras } = require('./roomCameraService');
function getReplaySources(socket = null) { function getReplaySources() {
const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster(); const roverSources = roverManager.getRoster().map((rover) => ({
const roverSources = roster type: 'rover',
.filter((rover) => roverManager.canReplayRoverId(rover.id)) id: String(rover.id),
.map((rover) => ({ label: rover.name || rover.id,
type: 'rover', }));
id: String(rover.id),
label: rover.name || rover.id,
color: rover.color || null,
}));
const roomSources = getRoomCameras().map((camera) => ({ const roomSources = getRoomCameras().map((camera) => ({
type: 'room', type: 'room',
id: String(camera.id), id: String(camera.id),
@@ -34,9 +30,9 @@ function normalizeSource(entry) {
return null; return null;
} }
function validateSources(list = [], socket = null) { function validateSources(list = []) {
const allowed = new Map(); const allowed = new Map();
getReplaySources(socket).forEach((source) => { getReplaySources().forEach((source) => {
allowed.set(`${source.type}:${source.id}`, source); allowed.set(`${source.type}:${source.id}`, source);
}); });
const unique = new Map(); const unique = new Map();
@@ -51,12 +47,11 @@ function validateSources(list = [], socket = null) {
return Array.from(unique.values()); return Array.from(unique.values());
} }
function getDefaultWebSources(assignment = {}, socket = null) { function getDefaultWebSources(assignment = {}) {
if (assignment?.roverId) { if (assignment?.roverId) {
const id = String(assignment.roverId); const id = String(assignment.roverId);
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id); const match = getReplaySources().find((entry) => entry.type === 'rover' && entry.id === id);
if (!match) return []; return [{ type: 'rover', id, label: match?.label || id }];
return [{ type: 'rover', id, label: match.label || id }];
} }
return []; return [];
} }
@@ -7,7 +7,7 @@ const { roomCameraStreamEvents, getRoomCameraState } = require('./roomCameraSnap
const SUBSCRIBE_LIMIT = 50; const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000; const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 1000; const STREAM_INTERVAL_MS = 800;
function passesMode(socket) { function passesMode(socket) {
const mode = getMode(); const mode = getMode();
+2 -46
View File
@@ -5,21 +5,6 @@ const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#00bcd4'; const ALERT_COLOR = '#00bcd4';
const { handleAck } = require('./commandService'); const { handleAck } = require('./commandService');
function coerceBool(value) {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
if (normalized === '1') return true;
if (normalized === '0') return false;
}
return null;
}
const HEARTBEAT_INTERVAL_MS = 15000;
function handleMessage(roverId, msg) { function handleMessage(roverId, msg) {
switch (msg.type) { switch (msg.type) {
case 'hello': case 'hello':
@@ -29,15 +14,9 @@ function handleMessage(roverId, msg) {
case 'sensor': case 'sensor':
roverManager.handleSensorFrame(roverId, msg); roverManager.handleSensorFrame(roverId, msg);
break; break;
case 'event': { case 'event':
const nightVisionOn = coerceBool(msg.data?.nightVisionOn);
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
roverManager.setNightVisionState(roverId, nightVisionOn);
break;
}
sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event }); sendAlert({ color: ALERT_COLOR, title: `${roverId} event`, message: msg.event });
break; break;
}
default: default:
break; break;
} }
@@ -45,24 +24,7 @@ function handleMessage(roverId, msg) {
roverWSS.on('connection', (ws) => { roverWSS.on('connection', (ws) => {
let roverId = null; let roverId = null;
ws.isAlive = true;
const heartbeat = setInterval(() => {
if (!ws.isAlive) {
logger.warn('Rover websocket unresponsive', roverId || 'unknown');
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
}, HEARTBEAT_INTERVAL_MS);
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (raw) => { ws.on('message', (raw) => {
ws.isAlive = true;
let msg; let msg;
try { try {
msg = JSON.parse(raw.toString()); msg = JSON.parse(raw.toString());
@@ -88,17 +50,11 @@ roverWSS.on('connection', (ws) => {
} else if (msg.type === 'ack') { } else if (msg.type === 'ack') {
handleAck(msg); handleAck(msg);
} else if (msg.type === 'event') { } else if (msg.type === 'event') {
const nightVisionOn = coerceBool(msg.data?.nightVisionOn); sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
if (msg.event === 'nightVision.state' && nightVisionOn != null) {
roverManager.setNightVisionState(roverId, nightVisionOn);
} else {
sendAlert({ color: ALERT_COLOR, title: `${roverId}`, message: msg.event });
}
} }
}); });
ws.on('close', () => { ws.on('close', () => {
clearInterval(heartbeat);
if (roverId) { if (roverId) {
roverManager.removeRover(roverId); roverManager.removeRover(roverId);
sendAlert({ color: ALERT_COLOR, title: 'Rover Offline', message: roverId }); sendAlert({ color: ALERT_COLOR, title: 'Rover Offline', message: roverId });
+35 -705
View File
@@ -5,7 +5,8 @@ const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#8bc34a'; const ALERT_COLOR = '#8bc34a';
const { parseSensorFrame } = require('../helpers/sensorDecoder'); const { parseSensorFrame } = require('../helpers/sensorDecoder');
const { MODES, getMode } = require('./modeManager'); const { MODES, getMode } = require('./modeManager');
const { isAdmin, isLockdownAdmin, roleEvents } = require('./roleService'); const { isAdmin, roleEvents } = require('./roleService');
const { isBannedSocket } = require('./moderationService');
const { publishEvent } = require('./eventBus'); const { publishEvent } = require('./eventBus');
const videoSessions = require('./videoSessions'); const videoSessions = require('./videoSessions');
@@ -21,171 +22,8 @@ const DOCK_GUARD_RETRY_MS = 10 * 1000;
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64'); const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
const BACKOFF_MS = 500; const BACKOFF_MS = 500;
const BACKOFF_SPEED = 300; const BACKOFF_SPEED = 300;
const PRIVATE_BUTTON_HOLD_MS = 3000;
const PRIVATE_AUTO_CLOSE_IDLE_MS = 30 * 60 * 1000;
const PRIVATE_AUTO_CLOSE_TICK_MS = 30000;
const SAFETY_BACKOFF_MIN = -500;
const SAFETY_BACKOFF_MAX = 500;
const backoffTimers = new Map(); // roverId -> Timeout const backoffTimers = new Map(); // roverId -> Timeout
const dockGuardStates = new Map(); // roverId -> guard state const dockGuardStates = new Map(); // roverId -> guard state
const privateButtonStates = new Map(); // roverId -> { pressedSince:number|null, latched:boolean }
const privateNoUsersSince = new Map(); // roverId -> timestamp|null
const privateSafetyTimers = new Map(); // roverId -> Timeout
const privateSafetyStates = new Map(); // roverId -> state
const DEFAULT_PRIVATE_SAFETY = Object.freeze({
speedLimitEnabled: false,
speedLimitMaxWheelSpeed: 250,
hardOvercurrentEnabled: false,
overcurrentStopMs: 300,
hardBumpEnabled: false,
bumpBackoffSpeed: 250,
bumpBackoffMs: 350,
cliffEnabled: false,
cliffBackoffSpeed: 250,
cliffBackoffMs: 500,
triggerCooldownMs: 800,
});
function parsePrivateMeta(meta = {}) {
const raw = meta?.private;
if (raw === true) {
return { enabled: true, safety: { ...DEFAULT_PRIVATE_SAFETY } };
}
if (!raw || typeof raw !== 'object') {
return { enabled: false, safety: { ...DEFAULT_PRIVATE_SAFETY } };
}
const safety = normalizePrivateSafety(raw.safety || {});
return {
enabled: Boolean(raw.enabled),
safety,
};
}
function clampInt(value, min, max, fallback) {
const num = Number.parseInt(value, 10);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
}
function normalizePrivateSafety(raw = {}) {
const source = raw && typeof raw === 'object' ? raw : {};
return {
speedLimitEnabled: Boolean(source.speedLimitEnabled),
speedLimitMaxWheelSpeed: clampInt(
source.speedLimitMaxWheelSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed,
),
hardOvercurrentEnabled: Boolean(source.hardOvercurrentEnabled),
overcurrentStopMs: clampInt(
source.overcurrentStopMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.overcurrentStopMs,
),
hardBumpEnabled: Boolean(source.hardBumpEnabled),
bumpBackoffSpeed: clampInt(
source.bumpBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.bumpBackoffSpeed,
),
bumpBackoffMs: clampInt(
source.bumpBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.bumpBackoffMs,
),
cliffEnabled: Boolean(source.cliffEnabled),
cliffBackoffSpeed: clampInt(
source.cliffBackoffSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.cliffBackoffSpeed,
),
cliffBackoffMs: clampInt(
source.cliffBackoffMs,
100,
5000,
DEFAULT_PRIVATE_SAFETY.cliffBackoffMs,
),
triggerCooldownMs: clampInt(
source.triggerCooldownMs,
100,
10000,
DEFAULT_PRIVATE_SAFETY.triggerCooldownMs,
),
};
}
function isPrivateRecord(record) {
return Boolean(record?.private?.enabled);
}
function isPrivateOpen(record) {
if (!isPrivateRecord(record)) return true;
return Boolean(record?.privateOpen);
}
function getPrivateSafety(record) {
if (!record) return { ...DEFAULT_PRIVATE_SAFETY };
return normalizePrivateSafety(record.privateSafety || record.private?.safety || {});
}
function shouldApplyPrivateSafety(record, socket) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) return false;
if (isLockdownAdmin(socket)) return false;
return true;
}
function isRoverVisibleToSocket(record, socket) {
if (!record) return false;
if (!isPrivateRecord(record)) return true;
if (isPrivateOpen(record)) return true;
return isLockdownAdmin(socket);
}
function getControlDenialReason(record, socket, options = {}) {
const { allowUser = false } = options;
if (!record) {
return 'Unknown rover';
}
if (!allowUser && !isAdmin(socket)) {
return 'Only admins can request control';
}
if (record.locked && !isAdmin(socket)) {
return 'Rover locked';
}
const mode = getMode();
if (!allowUser && mode === MODES.ADMIN && !isAdmin(socket)) {
return 'Admins only';
}
if (!allowUser && mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return 'Server in lockdown';
}
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
return 'Server in lockdown';
}
if (!isPrivateRecord(record)) {
return null;
}
if (!isPrivateOpen(record)) {
if (!isLockdownAdmin(socket)) {
return 'Private rover is closed';
}
return null;
}
if (isLockdownAdmin(socket)) {
return null;
}
const { isVerified } = require('./verificationService');
if (!isVerified(socket)) {
return 'Private rover requires verification';
}
return null;
}
function ensureRecord(id) { function ensureRecord(id) {
if (!rovers.has(id)) { if (!rovers.has(id)) {
@@ -200,13 +38,9 @@ function ensureRecord(id) {
locked: false, locked: false,
lockReason: null, lockReason: null,
batteryState: null, batteryState: null,
nightVisionState: null,
room: `rover:${id}`, room: `rover:${id}`,
lastSeen: Date.now(), lastSeen: Date.now(),
lastMovementAt: Date.now(), lastMovementAt: Date.now(),
private: { enabled: false },
privateOpen: true,
privateSafety: { ...DEFAULT_PRIVATE_SAFETY },
}); });
} }
return rovers.get(id); return rovers.get(id);
@@ -224,33 +58,10 @@ function upsertRover(meta, ws) {
record.meta = meta; record.meta = meta;
record.ws = ws; record.ws = ws;
record.lastSeen = Date.now(); record.lastSeen = Date.now();
const privateMeta = parsePrivateMeta(meta);
const wasPrivate = isPrivateRecord(record);
record.private = privateMeta;
record.privateSafety = normalizePrivateSafety(privateMeta.safety);
if (privateMeta.enabled) {
if (isNew || !wasPrivate) {
record.privateOpen = false;
}
} else {
record.privateOpen = true;
}
if (record.nightVisionState == null && meta?.nightVision?.enabled) {
const ledOn = Boolean(meta.nightVision.initialOn);
record.nightVisionState = {
nightVisionOn: !ledOn,
updatedAt: Date.now(),
};
}
rovers.set(id, record); rovers.set(id, record);
spectatorSockets.forEach((socketId) => { spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId); const sock = io.sockets.sockets.get(socketId);
if (!sock) return; sock?.join(record.room);
if (isRoverVisibleToSocket(record, sock)) {
sock.join(record.room);
} else {
sock.leave(record.room);
}
}); });
managerEvents.emit('rover', { roverId: id, action: 'upsert', record }); managerEvents.emit('rover', { roverId: id, action: 'upsert', record });
if (isNew) { if (isNew) {
@@ -265,11 +76,6 @@ function removeRover(id) {
if (!record) return; if (!record) return;
rovers.delete(id); rovers.delete(id);
stopDockGuard(id); stopDockGuard(id);
privateButtonStates.delete(id);
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
turnService.cleanupRover(id); turnService.cleanupRover(id);
spectatorSockets.forEach((socketId) => { spectatorSockets.forEach((socketId) => {
const sock = io.sockets.sockets.get(socketId); const sock = io.sockets.sockets.get(socketId);
@@ -280,27 +86,6 @@ function removeRover(id) {
publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: id } }); publishEvent({ source: 'roverManager', type: 'rover.offline', payload: { roverId: id } });
} }
function sendPrivateToggleTTS(roverId, open, reason) {
const { issueCommand } = require('./commandService');
let text = open ? 'Private rover is now open.' : 'Private rover is now closed.';
if (!open && reason === 'auto_idle') {
text = 'Private rover closed due to inactivity.';
} else if (reason === 'button_hold') {
text = open ? 'Private rover opened locally.' : 'Private rover closed locally.';
}
try {
issueCommand(roverId, {
type: 'tts',
tts: {
text,
speak: true,
},
});
} catch (err) {
logger.warn('Private toggle TTS failed', { roverId, reason, error: err.message });
}
}
function lockRover(id, locked, options = {}) { function lockRover(id, locked, options = {}) {
const record = rovers.get(id); const record = rovers.get(id);
if (!record) { if (!record) {
@@ -349,141 +134,25 @@ function lockRover(id, locked, options = {}) {
return record.locked; return record.locked;
} }
function setPrivateOpen(id, open, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
if (!isPrivateRecord(record)) {
throw new Error('Rover is not private');
}
const nextOpen = Boolean(open);
if (record.privateOpen === nextOpen) {
return nextOpen;
}
record.privateOpen = nextOpen;
const reason = options.reason || 'manual';
const silent = Boolean(options.silent);
if (!nextOpen) {
privateNoUsersSince.delete(id);
privateSafetyStates.delete(id);
clearTimeout(privateSafetyTimers.get(id));
privateSafetyTimers.delete(id);
}
if (!silent) {
sendAlert({
color: ALERT_COLOR,
title: nextOpen ? 'Private Rover Opened' : 'Private Rover Closed',
message: nextOpen ? `${id} opened (${reason}).` : `${id} closed (${reason}).`,
});
}
if (options.tts !== false) {
sendPrivateToggleTTS(id, nextOpen, reason);
}
publishEvent({
source: 'roverManager',
type: nextOpen ? 'rover.privateOpened' : 'rover.privateClosed',
payload: { roverId: id, reason },
});
managerEvents.emit('private', { roverId: id, open: nextOpen, reason });
broadcastRoster();
return nextOpen;
}
function setPrivateSafety(id, patch = {}, options = {}) {
const record = rovers.get(id);
if (!record) {
throw new Error('Unknown rover');
}
if (!isPrivateRecord(record)) {
throw new Error('Rover is not private');
}
const current = getPrivateSafety(record);
const next = normalizePrivateSafety({ ...current, ...(patch || {}) });
record.privateSafety = next;
const reason = options.reason || 'manual';
publishEvent({
source: 'roverManager',
type: 'rover.privateSafetyUpdated',
payload: { roverId: id, reason, safety: next },
});
managerEvents.emit('privateSafety', { roverId: id, reason, safety: next });
broadcastRoster();
return next;
}
function getRoster() { function getRoster() {
return Array.from(rovers.values()).map((record) => ({ return Array.from(rovers.values()).map((record) => ({
id: record.id, id: record.id,
name: record.meta?.name || record.id, name: record.meta?.name || record.id,
color: record.meta?.color || null,
battery: record.meta?.battery, battery: record.meta?.battery,
batteryState: record.batteryState, batteryState: record.batteryState,
maxWheelSpeed: record.meta?.maxWheelSpeed, maxWheelSpeed: record.meta?.maxWheelSpeed,
media: record.meta?.media, media: record.meta?.media,
cameraServo: record.meta?.cameraServo, cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio, audio: record.meta?.audio,
horn: record.meta?.horn, nightVision: record.meta?.nightVision,
nightVision: record.meta?.nightVision locked: record.locked,
? { ...record.meta.nightVision, state: record.nightVisionState } lockReason: record.lockReason,
: record.meta?.nightVision,
locked: record.locked || (isPrivateRecord(record) && !isPrivateOpen(record)),
lockReason:
record.lockReason || (isPrivateRecord(record) && !isPrivateOpen(record) ? 'private' : null),
lastSeen: record.lastSeen, lastSeen: record.lastSeen,
private: isPrivateRecord(record)
? {
enabled: true,
open: isPrivateOpen(record),
safety: getPrivateSafety(record),
}
: {
enabled: false,
open: true,
safety: getPrivateSafety(record),
},
})); }));
} }
function getRosterForSocket(socket) {
return getRoster()
.filter((entry) => {
const record = rovers.get(String(entry.id));
return isRoverVisibleToSocket(record, socket);
});
}
function syncSpectatorRooms() {
spectatorSockets.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (!socket) return;
for (const record of rovers.values()) {
if (isRoverVisibleToSocket(record, socket)) {
socket.join(record.room);
} else {
socket.leave(record.room);
}
}
});
}
function broadcastRoster() { function broadcastRoster() {
syncSpectatorRooms(); io.emit('rovers', getRoster());
io.sockets.sockets.forEach((socket) => {
socket.emit('rovers', getRosterForSocket(socket));
});
}
function setNightVisionState(roverId, nightVisionOn) {
const record = rovers.get(roverId);
if (!record) return;
if (typeof nightVisionOn !== 'boolean') return;
record.nightVisionState = {
nightVisionOn,
updatedAt: Date.now(),
};
broadcastRoster();
managerEvents.emit('rover', { roverId, action: 'nightVision', record });
} }
function computeBatteryState(record, sensors) { function computeBatteryState(record, sensors) {
@@ -505,14 +174,6 @@ function computeBatteryState(record, sensors) {
if (percent != null) { if (percent != null) {
percent = Math.max(0, Math.min(1, percent)); percent = Math.max(0, Math.min(1, percent));
} }
const percentDisplay = computeBatteryDisplayPercent({
charge,
full,
warn,
urgent,
percent,
capacity,
});
return { return {
charge, charge,
capacity, capacity,
@@ -520,227 +181,13 @@ function computeBatteryState(record, sensors) {
warn, warn,
urgent, urgent,
percent, percent,
percentDisplay, percentDisplay: percent == null ? null : Math.round(percent * 100),
warnActive: Boolean(warn != null && charge != null && charge <= warn), warnActive: Boolean(warn != null && charge != null && charge <= warn),
urgentActive: Boolean(urgent != null && charge != null && charge <= urgent), urgentActive: Boolean(urgent != null && charge != null && charge <= urgent),
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
} }
function computeBatteryDisplayPercent({ charge, full, warn, urgent, percent, capacity }) {
if (
charge != null &&
full != null &&
warn != null &&
urgent != null &&
full > warn &&
warn > urgent
) {
if (charge <= urgent) return 0;
if (charge <= warn) {
const t = (charge - urgent) / (warn - urgent);
return Math.round(Math.max(0, Math.min(1, t)) * 10);
}
if (charge >= full) return 100;
const t = (charge - warn) / (full - warn);
return Math.round((0.1 + Math.max(0, Math.min(1, t)) * 0.9) * 100);
}
if (percent != null && Number.isFinite(percent)) {
return Math.round(Math.max(0, Math.min(1, percent)) * 100);
}
if (charge != null && capacity != null && capacity > 0) {
const fallback = charge / capacity;
return Math.round(Math.max(0, Math.min(1, fallback)) * 100);
}
return null;
}
function getPrivateSafetyState(roverId) {
if (!privateSafetyStates.has(roverId)) {
privateSafetyStates.set(roverId, {
blockedUntil: 0,
lastOvercurrent: false,
lastBump: false,
lastCliff: false,
});
}
return privateSafetyStates.get(roverId);
}
function stopSafetyBackoffTimer(roverId) {
clearTimeout(privateSafetyTimers.get(roverId));
privateSafetyTimers.delete(roverId);
}
function triggerSafetyAction(record, mode, options = {}) {
if (!record) return;
const roverId = record.id;
const { issueCommand, setDriveCooldown } = require('./commandService');
const now = Date.now();
const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs);
const backoffMs = clampInt(options.backoffMs, 50, 5000, 0);
const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 0);
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
} catch (err) {
logger.warn('Private safety stop failed', { roverId, mode, error: err.message });
}
stopSafetyBackoffTimer(roverId);
if (backoffMs > 0 && backoffSpeed > 0) {
const speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed)));
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } });
} catch (err) {
logger.warn('Private safety backoff failed', { roverId, mode, error: err.message });
}
privateSafetyTimers.set(
roverId,
setTimeout(() => {
try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
} catch (err) {
logger.warn('Private safety backoff stop failed', { roverId, mode, error: err.message });
}
privateSafetyTimers.delete(roverId);
}, backoffMs),
);
}
setDriveCooldown(roverId, Math.max(cooldownMs, backoffMs));
const state = getPrivateSafetyState(roverId);
state.blockedUntil = now + Math.max(cooldownMs, backoffMs);
sendAlert({
color: ALERT_COLOR,
title: 'Private Safety',
message: `${roverId} ${mode} safety triggered.`,
});
publishEvent({
source: 'roverManager',
type: 'rover.privateSafetyTriggered',
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed },
});
}
function evaluatePrivateSafety(record, sensors) {
if (!record || !sensors) return;
const roverId = record.id;
const state = getPrivateSafetyState(roverId);
const overcurrent = Boolean(
sensors?.wheelOvercurrents?.leftWheel ||
sensors?.wheelOvercurrents?.rightWheel ||
sensors?.wheelOvercurrents?.mainBrush ||
sensors?.wheelOvercurrents?.sideBrush,
);
const bump = Boolean(sensors?.bumpsAndWheelDrops?.bumpLeft || sensors?.bumpsAndWheelDrops?.bumpRight);
const cliff = Boolean(
sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight,
);
const currentOver = overcurrent;
const currentBump = bump;
const currentCliff = cliff;
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
state.blockedUntil = 0;
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
return;
}
const safety = getPrivateSafety(record);
const now = Date.now();
if (now < Number(state.blockedUntil || 0)) {
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
return;
}
let triggered = false;
if (safety.hardOvercurrentEnabled && currentOver && !state.lastOvercurrent) {
triggerSafetyAction(record, 'overcurrent', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.overcurrentStopMs,
backoffSpeed: 0,
});
triggered = true;
} else if (safety.hardBumpEnabled && currentBump && !state.lastBump) {
triggerSafetyAction(record, 'bump', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.bumpBackoffMs,
backoffSpeed: safety.bumpBackoffSpeed,
});
triggered = true;
} else if (safety.cliffEnabled && currentCliff && !state.lastCliff) {
triggerSafetyAction(record, 'cliff', {
cooldownMs: safety.triggerCooldownMs,
backoffMs: safety.cliffBackoffMs,
backoffSpeed: safety.cliffBackoffSpeed,
});
triggered = true;
}
if (!triggered) {
state.blockedUntil = 0;
}
state.lastOvercurrent = currentOver;
state.lastBump = currentBump;
state.lastCliff = currentCliff;
}
function applyPrivateDriveSafety(roverId, socket, driveDirect = null) {
const record = rovers.get(String(roverId));
if (!record || !driveDirect || typeof driveDirect !== 'object') {
return driveDirect;
}
if (!shouldApplyPrivateSafety(record, socket)) {
return driveDirect;
}
const safety = getPrivateSafety(record);
if (!safety.speedLimitEnabled) {
return driveDirect;
}
const limit = clampInt(
safety.speedLimitMaxWheelSpeed,
1,
500,
DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed,
);
const left = clampInt(driveDirect.left, -500, 500, 0);
const right = clampInt(driveDirect.right, -500, 500, 0);
return {
...driveDirect,
left: Math.max(-limit, Math.min(limit, left)),
right: Math.max(-limit, Math.min(limit, right)),
};
}
function handlePrivateButtonHold(record, sensors) {
if (!record || !isPrivateRecord(record)) return;
const buttons = sensors?.buttons || null;
const pressed = Boolean(buttons?.spot && buttons?.clean && buttons?.dock);
const roverId = record.id;
const now = Date.now();
const state = privateButtonStates.get(roverId) || { pressedSince: null, latched: false };
if (!pressed) {
if (state.pressedSince != null || state.latched) {
privateButtonStates.set(roverId, { pressedSince: null, latched: false });
}
return;
}
if (state.pressedSince == null) {
state.pressedSince = now;
}
if (!state.latched && now - state.pressedSince >= PRIVATE_BUTTON_HOLD_MS) {
const nextOpen = !isPrivateOpen(record);
try {
setPrivateOpen(roverId, nextOpen, { reason: 'button_hold', tts: true });
} catch (err) {
logger.warn('Private button toggle failed', { roverId, error: err.message });
}
state.latched = true;
}
privateButtonStates.set(roverId, state);
}
function handleSensorFrame(roverId, frame) { function handleSensorFrame(roverId, frame) {
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (!record) return; if (!record) return;
@@ -762,8 +209,6 @@ function handleSensorFrame(roverId, frame) {
if (bumps?.bumpLeft || bumps?.bumpRight) { if (bumps?.bumpLeft || bumps?.bumpRight) {
record.lastBumpAt = Date.now(); record.lastBumpAt = Date.now();
} }
handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded);
io.to(record.room).volatile.emit('sensorFrame', { io.to(record.room).volatile.emit('sensorFrame', {
roverId, roverId,
frame, frame,
@@ -985,9 +430,21 @@ function removeSocket(socket) {
function requestControl(roverId, socket, options = {}) { function requestControl(roverId, socket, options = {}) {
const { force = false, allowUser = false } = options; const { force = false, allowUser = false } = options;
const record = rovers.get(roverId); const record = rovers.get(roverId);
const denied = getControlDenialReason(record, socket, { allowUser }); if (!record) {
if (denied) { throw new Error('Unknown rover');
throw new Error(denied); }
if (!allowUser && !isAdmin(socket)) {
throw new Error('Only admins can request control');
}
if (record.locked && !isAdmin(socket)) {
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); record.drivers.add(socket.id);
if (!socketToRovers.has(socket.id)) { if (!socketToRovers.has(socket.id)) {
@@ -1029,13 +486,6 @@ function isDriver(roverId, socket) {
} }
function canDrive(roverId, socket) { function canDrive(roverId, socket) {
const record = rovers.get(roverId);
if (!record) return false;
const denied = getControlDenialReason(record, socket, { allowUser: true });
if (denied) {
return false;
}
const mode = getMode();
if (isAdmin(socket)) { if (isAdmin(socket)) {
return true; return true;
} }
@@ -1081,14 +531,6 @@ function hasOtherDrivers(record, socketId) {
} }
function canSwitchRover(socket, targetRoverId) { function canSwitchRover(socket, targetRoverId) {
const target = rovers.get(targetRoverId);
if (!target) {
return { ok: false, message: 'Unknown rover' };
}
const denied = getControlDenialReason(target, socket, { allowUser: true });
if (denied) {
return { ok: false, message: denied };
}
const currentId = getPrimaryRoverForSocket(socket.id); const currentId = getPrimaryRoverForSocket(socket.id);
if (!currentId || currentId === targetRoverId) { if (!currentId || currentId === targetRoverId) {
return { ok: true, currentId }; return { ok: true, currentId };
@@ -1106,34 +548,12 @@ function canSwitchRover(socket, targetRoverId) {
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' }; return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
} }
function canSeeRover(roverId, socket) {
const record = rovers.get(String(roverId));
return isRoverVisibleToSocket(record, socket);
}
function canRequestControl(roverId, socket, options = {}) {
const record = rovers.get(String(roverId));
const denied = getControlDenialReason(record, socket, options);
return { ok: !denied, reason: denied || null };
}
function canReplayRoverId(roverId) {
const record = rovers.get(String(roverId));
if (!record) return false;
if (!isPrivateRecord(record)) return true;
return isPrivateOpen(record);
}
module.exports = { module.exports = {
upsertRover, upsertRover,
removeRover, removeRover,
lockRover, lockRover,
setPrivateOpen,
setPrivateSafety,
getRoster, getRoster,
getRosterForSocket,
broadcastRoster, broadcastRoster,
setNightVisionState,
handleSensorFrame, handleSensorFrame,
requestControl, requestControl,
releaseControl, releaseControl,
@@ -1146,10 +566,6 @@ module.exports = {
managerEvents, managerEvents,
getRoversForSocket, getRoversForSocket,
getPrimaryRoverForSocket, getPrimaryRoverForSocket,
canSeeRover,
canRequestControl,
applyPrivateDriveSafety,
canReplayRoverId,
}; };
roleEvents.on('change', ({ socket, role }) => { roleEvents.on('change', ({ socket, role }) => {
@@ -1161,10 +577,11 @@ roleEvents.on('change', ({ socket, role }) => {
}); });
io.on('connection', (socket) => { io.on('connection', (socket) => {
tickPrivateAutoClose(); if (!isBannedSocket(socket)) {
socket.emit('rovers', getRosterForSocket(socket)); socket.emit('rovers', getRoster());
if (socket.data?.role === 'spectator') { if (socket.data?.role === 'spectator') {
enableSpectator(socket); enableSpectator(socket);
}
} }
function handleRequestControl({ roverId, force } = {}, cb = () => {}) { function handleRequestControl({ roverId, force } = {}, cb = () => {}) {
@@ -1172,17 +589,10 @@ io.on('connection', (socket) => {
if (socket.data?.role === 'spectator') { if (socket.data?.role === 'spectator') {
throw new Error('Spectators cannot drive'); throw new Error('Spectators cannot drive');
} }
const mode = getMode(); if ((getMode() === MODES.ADMIN || getMode() === MODES.LOCKDOWN) && !isAdmin(socket)) {
if (
(mode === MODES.ADMIN && !isAdmin(socket)) ||
(mode === MODES.LOCKDOWN && !isLockdownAdmin(socket))
) {
throw new Error('Admins only'); throw new Error('Admins only');
} }
const fallbackTargetId = Array.from(rovers.keys()).find( const targetId = roverId || Array.from(rovers.keys())[0];
(id) => canRequestControl(id, socket, { allowUser: true }).ok,
);
const targetId = roverId || fallbackTargetId;
if (!targetId) { if (!targetId) {
throw new Error('No rovers available'); throw new Error('No rovers available');
} }
@@ -1229,29 +639,13 @@ io.on('connection', (socket) => {
} }
function handleLockToggle({ roverId, locked } = {}, cb = () => {}) { function handleLockToggle({ roverId, locked } = {}, cb = () => {}) {
const record = rovers.get(roverId); if (!isAdmin(socket)) {
if (!record) {
cb({ error: 'Unknown rover' });
return;
}
const isPrivate = isPrivateRecord(record);
if (isPrivate && !isLockdownAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
if (!isPrivate && !isAdmin(socket)) {
cb({ error: 'Not authorized' }); cb({ error: 'Not authorized' });
return; return;
} }
try { try {
if (isPrivate) { lockRover(roverId, locked, { reason: 'manual' });
const open = !Boolean(locked); logger.info('Lock state changed', roverId, locked);
setPrivateOpen(roverId, open, { reason: 'manual' });
logger.info('Private state changed', roverId, { open });
} else {
lockRover(roverId, locked, { reason: 'manual' });
logger.info('Lock state changed', roverId, locked);
}
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
logger.warn('Lock change failed', roverId, err.message); logger.warn('Lock change failed', roverId, err.message);
@@ -1260,28 +654,6 @@ io.on('connection', (socket) => {
} }
} }
function handlePrivateSafetySet({ roverId, safety } = {}, cb = () => {}) {
const record = rovers.get(roverId);
if (!record) {
cb({ error: 'Unknown rover' });
return;
}
if (!isPrivateRecord(record)) {
cb({ error: 'Rover is not private' });
return;
}
if (!isLockdownAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
const next = setPrivateSafety(roverId, safety || {}, { reason: 'manual' });
cb({ success: true, safety: next });
} catch (err) {
cb({ error: err.message });
}
}
function handleSubscribeAll(_, cb = () => {}) { function handleSubscribeAll(_, cb = () => {}) {
if (socket.data?.role !== 'spectator') { if (socket.data?.role !== 'spectator') {
cb({ error: 'Spectator role required' }); cb({ error: 'Spectator role required' });
@@ -1293,11 +665,7 @@ io.on('connection', (socket) => {
} }
logger.info('Spectator subscribing to all rovers', socket.id); logger.info('Spectator subscribing to all rovers', socket.id);
for (const record of rovers.values()) { for (const record of rovers.values()) {
if (isRoverVisibleToSocket(record, socket)) { socket.join(record.room);
socket.join(record.room);
} else {
socket.leave(record.room);
}
} }
cb({ success: true }); cb({ success: true });
} }
@@ -1308,20 +676,16 @@ io.on('connection', (socket) => {
socket.on('session:releaseControl', handleReleaseControl); socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle); socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle); socket.on('session:lockRover', handleLockToggle);
socket.on('privateSafety:set', handlePrivateSafetySet);
socket.on('session:privateSafety:set', handlePrivateSafetySet);
socket.on('subscribeAll', handleSubscribeAll); socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll); socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => { socket.on('disconnecting', () => {
logger.info('Socket disconnecting', socket.id); logger.info('Socket disconnecting', socket.id);
removeSocket(socket); removeSocket(socket);
tickPrivateAutoClose();
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
logger.info('Socket disconnected', socket.id); logger.info('Socket disconnected', socket.id);
removeSocket(socket); removeSocket(socket);
tickPrivateAutoClose();
}); });
}); });
@@ -1329,11 +693,7 @@ function enableSpectator(socket) {
if (!socket?.id || spectatorSockets.has(socket.id)) return; if (!socket?.id || spectatorSockets.has(socket.id)) return;
spectatorSockets.add(socket.id); spectatorSockets.add(socket.id);
for (const record of rovers.values()) { for (const record of rovers.values()) {
if (isRoverVisibleToSocket(record, socket)) { socket.join(record.room);
socket.join(record.room);
} else {
socket.leave(record.room);
}
} }
} }
@@ -1344,33 +704,3 @@ function disableSpectator(socket) {
socket.leave(record.room); socket.leave(record.room);
} }
} }
function tickPrivateAutoClose() {
const now = Date.now();
const onlineCount = io.sockets.sockets.size;
for (const record of rovers.values()) {
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
privateNoUsersSince.delete(record.id);
continue;
}
if (onlineCount > 0) {
privateNoUsersSince.delete(record.id);
continue;
}
const since = privateNoUsersSince.get(record.id) || now;
if (!privateNoUsersSince.has(record.id)) {
privateNoUsersSince.set(record.id, since);
continue;
}
if (now - since >= PRIVATE_AUTO_CLOSE_IDLE_MS) {
try {
setPrivateOpen(record.id, false, { reason: 'auto_idle', tts: true });
} catch (err) {
logger.warn('Private auto-close failed', { roverId: record.id, error: err.message });
}
privateNoUsersSince.delete(record.id);
}
}
}
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
@@ -117,13 +117,11 @@ roverSnapshotEvents.on('status', ({ id, error }) => {
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('roverSnapshot:subscribe', (payload = {}, cb = () => {}) => { socket.on('roverSnapshot:subscribe', (payload = {}, cb = () => {}) => {
const visibleRoster = roverManager.getRosterForSocket(socket);
const visibleIds = visibleRoster.map((rover) => String(rover.id));
const list = Array.isArray(payload?.ids) const list = Array.isArray(payload?.ids)
? payload.ids.map(String) ? payload.ids.map(String)
: payload?.roverId || payload?.id : payload?.roverId || payload?.id
? [String(payload.roverId || payload.id)] ? [String(payload.roverId || payload.id)]
: visibleIds; : roverManager.getRoster().map((rover) => rover.id);
const uniqueIds = Array.from(new Set(list)); const uniqueIds = Array.from(new Set(list));
try { try {
if (!allowSubscribe(socket.id)) { if (!allowSubscribe(socket.id)) {
@@ -133,7 +131,7 @@ io.on('connection', (socket) => {
if (!canViewSnapshots(socket)) { if (!canViewSnapshots(socket)) {
throw new Error('Not authorized for rover snapshots'); throw new Error('Not authorized for rover snapshots');
} }
const rosterIds = new Set(visibleIds); const rosterIds = new Set(roverManager.getRoster().map((entry) => String(entry.id)));
const validIds = uniqueIds.filter((id) => rosterIds.has(String(id))); const validIds = uniqueIds.filter((id) => rosterIds.has(String(id)));
validIds.forEach((roverId) => addSubscription(socket, roverId)); validIds.forEach((roverId) => addSubscription(socket, roverId));
validIds.forEach((roverId) => { validIds.forEach((roverId) => {
@@ -1,52 +0,0 @@
const { spawn } = require('child_process');
const io = require('../globals/io');
const logger = require('../globals/logger').child('serverControlService');
const { isAdmin } = require('./roleService');
const { sendAlert } = require('./alertService');
const ALERT_COLOR = '#ff5722';
let rebootPending = false;
function scheduleSystemReboot() {
if (rebootPending) {
throw new Error('Server reboot already pending');
}
rebootPending = true;
setTimeout(() => {
logger.warn('Issuing system reboot command');
try {
const child = spawn('systemctl', ['reboot'], {
detached: true,
stdio: 'ignore',
});
child.unref();
} catch (err) {
rebootPending = false;
logger.error('Server reboot command failed', err.message);
}
}, 400);
}
io.on('connection', (socket) => {
socket.on('server:reboot', (_, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Not authorized' });
return;
}
try {
scheduleSystemReboot();
const who = socket?.data?.user?.username || socket.id;
logger.warn('Server reboot requested', { by: who });
sendAlert({
color: ALERT_COLOR,
title: 'Server Reboot',
message: `Reboot requested by ${who}`,
});
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
+11 -135
View File
@@ -9,37 +9,22 @@ const { getActiveDrivers, getTurnQueues, turnEvents } = require('./turnService')
const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService');
const { getNickname, nicknameEvents } = require('./nicknameService'); const { getNickname, nicknameEvents } = require('./nicknameService');
const {
getVerificationStateForSocket,
getIdentitySummary,
verificationEvents,
} = require('./verificationService');
const { getReplayState, replayEvents } = require('./replayService'); const { getReplayState, replayEvents } = require('./replayService');
const { getReplaySources } = require('./replaySourceService'); const { getReplaySources } = require('./replaySourceService');
const { getHealthSnapshot } = require('./healthService'); const { getHealthSnapshot } = require('./healthService');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getCommunityGoal } = require('./communityGoalService'); const { getCommunityGoal } = require('./communityGoalService');
const { getAdminReason } = require('./adminReasonService');
const { subscribe } = require('./eventBus'); const { subscribe } = require('./eventBus');
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver'); const { isBannedSocket } = require('./moderationService');
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
const config = loadConfig(); const discordInvite = loadConfig().discord?.invite || null;
const discordInvite = config.discord?.invite || null; const kofiLink = loadConfig().kofi?.link || null;
const kofiLink = config.kofi?.link || null;
const serverTimezone = config.timezone || null;
const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured'); logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured'); logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
const ACTIVITY_SYNC_COOLDOWN_MS = 3000; const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
let lastActivitySync = 0; let lastActivitySync = 0;
let pendingActivitySync = null; let pendingActivitySync = null;
let lastNightVisionSync = 0;
let pendingNightVisionSync = null;
function buildUserEntry(socket) { function buildUserEntry(socket) {
if (!socket) return null; if (!socket) return null;
@@ -54,88 +39,37 @@ function buildUserEntry(socket) {
}; };
} }
function filterVisibleRoverId(socket, roverId) {
if (!roverId) return null;
return roverManager.canSeeRover(roverId, socket) ? roverId : null;
}
function filterActiveDriversForSocket(activeDrivers = {}, socket) {
const next = {};
Object.entries(activeDrivers || {}).forEach(([roverId, socketId]) => {
if (!roverManager.canSeeRover(roverId, socket)) return;
next[roverId] = socketId;
});
return next;
}
function filterTurnQueuesForSocket(turnQueues = {}, socket) {
const next = {};
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
if (!roverManager.canSeeRover(roverId, socket)) return;
next[roverId] = info;
});
return next;
}
function buildSession(socket) { function buildSession(socket) {
const users = Array.from(io.sockets.sockets.values()) const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock)) .map((sock) => buildUserEntry(sock))
.filter(Boolean) .filter(Boolean);
.map((entry) => ({
...entry,
roverId: filterVisibleRoverId(socket, entry.roverId),
}));
const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || '');
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
const socials =
configuredSocials?.length
? configuredSocials
: [
...(discordInvite ? [{ id: 'discord', label: 'Discord', url: discordInvite }] : []),
...(kofiLink ? [{ id: 'kofi', label: 'Ko-fi', url: kofiLink }] : []),
];
return { return {
socketId: socket?.id || null, socketId: socket?.id || null,
role: getRole(socket), role: getRole(socket),
mode: getMode(), mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)), roster: roverManager.getRoster(),
roster, assignment: assignmentService.describeAssignment(socket?.id || ''),
assignment: { activeDrivers: getActiveDrivers(),
...assignment, turnQueues: getTurnQueues(),
roverId: assignmentRoverId,
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
},
activeDrivers,
turnQueues,
roomCameras: getRoomCameras(), roomCameras: getRoomCameras(),
homeAssistant: getHomeAssistantState(), homeAssistant: getHomeAssistantState(),
replay: getReplayState(), replay: getReplayState(),
replaySources: getReplaySources(socket), replaySources: getReplaySources(),
health: getHealthSnapshot(), health: getHealthSnapshot(),
communityGoal: getCommunityGoal(), communityGoal: getCommunityGoal(),
adminReason: getAdminReason(),
users, users,
socials,
discord: { discord: {
invite: discordInvite, invite: discordInvite,
}, },
timezone: serverTimezone,
kofi: { kofi: {
link: kofiLink, link: kofiLink,
}, },
identity: getIdentitySummary(socket),
verification: getVerificationStateForSocket(socket),
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(),
}; };
} }
function syncSocket(socket) { function syncSocket(socket) {
if (!socket) return; if (!socket) return;
if (isBannedSocket(socket)) return;
const payload = buildSession(socket); const payload = buildSession(socket);
logger.info('Syncing session', socket.id, payload.role, payload.assignment); logger.info('Syncing session', socket.id, payload.role, payload.assignment);
socket.emit('session:sync', payload); socket.emit('session:sync', payload);
@@ -170,31 +104,7 @@ modeEvents.on('change', () => {
syncAll(); syncAll();
}); });
managerEvents.on('rover', (event = {}) => { managerEvents.on('rover', () => {
if (event.action === 'nightVision') {
const now = Date.now();
const elapsed = now - lastNightVisionSync;
if (elapsed >= NIGHT_VISION_SYNC_COOLDOWN_MS) {
lastNightVisionSync = now;
logger.info('Night vision update; syncing all clients (immediate)');
syncAll();
return;
}
if (!pendingNightVisionSync) {
const delay = NIGHT_VISION_SYNC_COOLDOWN_MS - elapsed;
pendingNightVisionSync = setTimeout(() => {
lastNightVisionSync = Date.now();
pendingNightVisionSync = null;
logger.info('Night vision update; syncing all clients (delayed)');
syncAll();
}, delay);
}
return;
}
if (pendingNightVisionSync) {
clearTimeout(pendingNightVisionSync);
pendingNightVisionSync = null;
}
logger.info('Rover roster change; syncing all clients'); logger.info('Rover roster change; syncing all clients');
syncAll(); syncAll();
}); });
@@ -204,16 +114,6 @@ managerEvents.on('lock', ({ roverId, locked }) => {
syncAll(); syncAll();
}); });
managerEvents.on('private', ({ roverId, open }) => {
logger.info('Private rover visibility change', roverId, open);
syncAll();
});
managerEvents.on('privateSafety', ({ roverId }) => {
logger.info('Private rover safety config changed', roverId);
syncAll();
});
managerEvents.on('driver', ({ socketId }) => { managerEvents.on('driver', ({ socketId }) => {
if (!socketId) return; if (!socketId) return;
const socket = io.sockets.sockets.get(socketId); const socket = io.sockets.sockets.get(socketId);
@@ -287,35 +187,11 @@ nicknameEvents.on('change', ({ socketId }) => {
} }
}); });
verificationEvents.on('change', ({ socketId } = {}) => {
if (socketId) {
const socket = io.sockets.sockets.get(socketId);
if (socket) {
syncSocket(socket);
return;
}
}
syncAll();
});
subscribe('communityGoal.updated', () => { subscribe('communityGoal.updated', () => {
logger.info('Community goal updated; syncing all clients'); logger.info('Community goal updated; syncing all clients');
syncAll(); syncAll();
}); });
subscribe('adminReason.updated', () => {
logger.info('Admin reason updated; syncing all clients');
syncAll();
});
audioForwardEvents.on('change', () => {
syncAll();
});
audioLevelsEvents.on('change', () => {
syncAll();
});
// sync all sockets 20 seconds // sync all sockets 20 seconds
setInterval(() => { setInterval(() => {
logger.info('Periodic session sync for all clients'); logger.info('Periodic session sync for all clients');
-8
View File
@@ -72,13 +72,6 @@ function canDrive(roverId, socket) {
return activeDrivers.get(roverId) === socket.id; return activeDrivers.get(roverId) === socket.id;
} }
function isQueuedDriver(roverId, socketId) {
if (!socketId) return false;
const queue = driverQueues.get(roverId);
if (!queue) return false;
return queue.queue.includes(socketId);
}
function ensureQueue(roverId) { function ensureQueue(roverId) {
if (!driverQueues.has(roverId)) { if (!driverQueues.has(roverId)) {
driverQueues.set(roverId, { queue: [], current: null, timer: null }); driverQueues.set(roverId, { queue: [], current: null, timer: null });
@@ -319,7 +312,6 @@ module.exports = {
driverRemoved, driverRemoved,
cleanupRover, cleanupRover,
canDrive, canDrive,
isQueuedDriver,
getActiveDrivers, getActiveDrivers,
turnEvents, turnEvents,
getTurnQueues, getTurnQueues,
-530
View File
@@ -1,530 +0,0 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('verificationService');
const { publishEvent } = require('./eventBus');
const { getSocketIp, normalizeIp } = require('../helpers/ipResolver');
const { getNickname, setNickname } = require('./nicknameService');
const { getRole, roleEvents } = require('./roleService');
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const STORE_PATH = path.join(DATA_DIR, 'verified-users.json');
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
const verificationEvents = new EventEmitter();
let cache = null;
function sanitizeNickname(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
return trimmed.replace(/\*/g, 'nope').slice(0, 32);
}
function normalizeStoreShape(store) {
const next = store && typeof store === 'object' ? store : {};
return {
verifiedUsers: Array.isArray(next.verifiedUsers) ? next.verifiedUsers : [],
pendingRequests: Array.isArray(next.pendingRequests) ? next.pendingRequests : [],
dmMessages: Array.isArray(next.dmMessages) ? next.dmMessages : [],
};
}
function loadStore() {
if (cache) return cache;
try {
const raw = fs.readFileSync(STORE_PATH, 'utf8');
cache = normalizeStoreShape(JSON.parse(raw));
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load verification store', err.message);
}
cache = normalizeStoreShape({});
}
return cache;
}
function writeStore(next) {
const normalized = normalizeStoreShape(next);
fs.mkdirSync(DATA_DIR, { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
cache = normalized;
return cache;
}
function withStore(mutator) {
const current = loadStore();
const draft = {
verifiedUsers: current.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] })),
pendingRequests: current.pendingRequests.map((entry) => ({ ...entry })),
dmMessages: current.dmMessages.map((entry) => ({ ...entry })),
};
const result = mutator(draft);
writeStore(draft);
return result;
}
function generateCookieUserId() {
return `cu_${crypto.randomBytes(16).toString('hex')}`;
}
function normalizeCookieUserId(value) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw) return '';
return raw.toLowerCase();
}
function isValidCookieUserId(value) {
return COOKIE_USER_ID_RE.test(normalizeCookieUserId(value));
}
function getKnownIp(socket) {
return normalizeIp(getSocketIp(socket));
}
function ensureSocketData(socket) {
socket.data = socket.data || {};
return socket.data;
}
function findVerifiedMatch(store, { cookieUserId, ip }) {
if (!cookieUserId && !ip) return null;
const byCookie = cookieUserId
? store.verifiedUsers.find((entry) => normalizeCookieUserId(entry.cookieUserId) === cookieUserId) || null
: null;
if (byCookie) return byCookie;
if (!ip) return null;
return (
store.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(ip)) || null
);
}
function emitChange(reason, payload = {}) {
verificationEvents.emit('change', { reason, ...payload });
}
function reevaluateSocketVerification(socket) {
if (!socket) return { isVerified: false, matchedRecordId: null, reason: 'missing_socket' };
const store = loadStore();
const data = ensureSocketData(socket);
const role = getRole(socket);
const cookieUserId = normalizeCookieUserId(data.cookieUserId);
const nickname = sanitizeNickname(getNickname(socket));
const ip = getKnownIp(socket);
if (role === 'lockdown') {
data.isVerified = true;
data.verifiedRecordId = null;
return {
isVerified: true,
matchedRecordId: null,
reason: 'lockdown_admin',
cookieUserId,
nickname,
ip,
};
}
const match = findVerifiedMatch(store, { cookieUserId, ip });
const nicknameMatches = Boolean(match && nickname && sanitizeNickname(match.nickname) === nickname);
let isVerified = false;
let reason = 'no_match';
if (match && nicknameMatches) {
isVerified = true;
reason = 'matched';
} else if (match && !nicknameMatches) {
reason = 'nickname_mismatch';
}
data.isVerified = isVerified;
data.verifiedRecordId = isVerified ? match.id : null;
if (isVerified) {
withStore((draft) => {
const record = draft.verifiedUsers.find((entry) => entry.id === match.id);
if (!record) return;
record.updatedAt = Date.now();
record.nickname = nickname;
if (ip && !record.knownIps.includes(ip)) {
record.knownIps.push(ip);
}
});
}
return {
isVerified,
matchedRecordId: isVerified ? match.id : null,
reason,
cookieUserId,
nickname,
ip,
};
}
function identifySocket(socket, payload = {}) {
if (!socket) {
throw new Error('Socket required');
}
const data = ensureSocketData(socket);
const incomingKey = normalizeCookieUserId(payload.cookieUserId);
if (incomingKey && !isValidCookieUserId(incomingKey)) {
throw new Error('Invalid identity key format.');
}
const currentKey = normalizeCookieUserId(data.cookieUserId);
const safeCurrentKey = isValidCookieUserId(currentKey) ? currentKey : '';
data.cookieUserId = incomingKey || safeCurrentKey || generateCookieUserId();
const incomingNickname = sanitizeNickname(payload.nickname);
if (incomingNickname) {
try {
if (incomingNickname !== getNickname(socket)) {
setNickname(socket, incomingNickname);
}
} catch (err) {
logger.warn('Failed to set nickname from identify', err.message);
}
}
const verification = reevaluateSocketVerification(socket);
emitChange('identify', { socketId: socket.id });
return {
cookieUserId: data.cookieUserId,
isVerified: verification.isVerified,
reason: verification.reason,
identifiedAt: Date.now(),
};
}
function getVerificationStatus(socket) {
const data = socket?.data || {};
return {
isVerified: Boolean(data.isVerified),
recordId: data.verifiedRecordId || null,
};
}
function getIdentitySummary(socket) {
const data = socket?.data || {};
return {
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
nickname: getNickname(socket) || null,
};
}
function getPendingRequestForIdentity(cookieUserId) {
const key = normalizeCookieUserId(cookieUserId);
if (!key) return null;
const store = loadStore();
return store.pendingRequests.find((entry) => entry.status === 'pending' && entry.cookieUserId === key) || null;
}
function listVerifiedUsers() {
const store = loadStore();
return store.verifiedUsers.map((entry) => ({ ...entry, knownIps: [...(entry.knownIps || [])] }));
}
function resolveVerifiedUserSelector(selector) {
const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' };
const store = loadStore();
const byCookie = store.verifiedUsers.find((entry) => entry.cookieUserId === value) || null;
if (byCookie) return { record: byCookie };
const byNickname = store.verifiedUsers.filter((entry) => sanitizeNickname(entry.nickname) === sanitizeNickname(value));
if (byNickname.length === 1) return { record: byNickname[0] };
if (byNickname.length > 1) return { error: 'ambiguous_nickname' };
return { error: 'not_found' };
}
function removeVerifiedUser(selector, removedBy = null) {
const resolved = resolveVerifiedUserSelector(selector);
if (resolved.error) {
throw new Error(
resolved.error === 'ambiguous_nickname'
? 'Nickname matches multiple users; remove by cookieUserId.'
: 'Verified user not found.',
);
}
const target = resolved.record;
let removed = null;
withStore((draft) => {
const before = draft.verifiedUsers.length;
draft.verifiedUsers = draft.verifiedUsers.filter((entry) => entry.id !== target.id);
if (draft.verifiedUsers.length !== before) {
removed = target;
}
});
if (!removed) {
throw new Error('Verified user not found.');
}
io.sockets.sockets.forEach((socket) => {
const data = ensureSocketData(socket);
if (normalizeCookieUserId(data.cookieUserId) === removed.cookieUserId) {
reevaluateSocketVerification(socket);
}
});
emitChange('remove', { cookieUserId: removed.cookieUserId });
publishEvent({
source: 'verification',
type: 'verification.userRemoved',
payload: {
cookieUserId: removed.cookieUserId,
nickname: removed.nickname,
removedBy,
removedAt: Date.now(),
},
});
return removed;
}
function createVerificationRequest(socket) {
if (!socket) {
throw new Error('Socket required');
}
const data = ensureSocketData(socket);
const cookieUserId = normalizeCookieUserId(data.cookieUserId);
const nickname = sanitizeNickname(getNickname(socket));
const ip = getKnownIp(socket);
if (data.isVerified) {
throw new Error('You are already verified.');
}
if (!cookieUserId) {
throw new Error('Identity key missing. Reconnect and try again.');
}
if (!isValidCookieUserId(cookieUserId)) {
throw new Error('Identity key format invalid.');
}
if (!nickname) {
throw new Error('Nickname required before requesting verification.');
}
const existingPending = getPendingRequestForIdentity(cookieUserId);
if (existingPending) {
return existingPending;
}
const request = {
id: `vr_${crypto.randomBytes(8).toString('hex')}`,
status: 'pending',
cookieUserId,
nickname,
ip,
socketId: socket.id,
createdAt: Date.now(),
resolvedAt: null,
resolvedBy: null,
decision: null,
};
withStore((draft) => {
draft.pendingRequests.push(request);
});
publishEvent({ source: 'verification', type: 'verification.requested', payload: request });
emitChange('request', { requestId: request.id, socketId: socket.id });
return request;
}
function attachDmMessage(requestId, messageId, adminDiscordId) {
if (!requestId || !messageId) return;
withStore((draft) => {
const exists = draft.dmMessages.find((entry) => entry.messageId === messageId);
if (exists) return;
draft.dmMessages.push({
requestId,
messageId,
adminDiscordId: adminDiscordId ? String(adminDiscordId) : null,
createdAt: Date.now(),
});
});
}
function getPendingRequestById(requestId) {
if (!requestId) return null;
const store = loadStore();
return store.pendingRequests.find((entry) => entry.id === requestId && entry.status === 'pending') || null;
}
function getRequestByMessageId(messageId) {
if (!messageId) return null;
const store = loadStore();
const map = store.dmMessages.find((entry) => entry.messageId === messageId);
if (!map) return null;
const request = store.pendingRequests.find((entry) => entry.id === map.requestId) || null;
return request ? { request, map } : null;
}
function approveRequest(requestId, actorDiscordId) {
const request = getPendingRequestById(requestId);
if (!request) {
throw new Error('Request not found or already resolved.');
}
const approvedAt = Date.now();
const actor = actorDiscordId ? String(actorDiscordId) : null;
withStore((draft) => {
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
if (!pending || pending.status !== 'pending') {
throw new Error('Request not found or already resolved.');
}
pending.status = 'approved';
pending.decision = 'approved';
pending.resolvedAt = approvedAt;
pending.resolvedBy = actor;
let target =
draft.verifiedUsers.find((entry) => entry.cookieUserId === pending.cookieUserId) ||
draft.verifiedUsers.find((entry) => Array.isArray(entry.knownIps) && entry.knownIps.includes(pending.ip));
if (!target) {
target = {
id: `vu_${crypto.randomBytes(8).toString('hex')}`,
cookieUserId: pending.cookieUserId,
nickname: pending.nickname,
knownIps: pending.ip ? [pending.ip] : [],
createdAt: approvedAt,
updatedAt: approvedAt,
approvedBy: actor,
};
draft.verifiedUsers.push(target);
} else {
target.cookieUserId = pending.cookieUserId;
target.nickname = pending.nickname;
if (pending.ip && !target.knownIps.includes(pending.ip)) {
target.knownIps.push(pending.ip);
}
target.updatedAt = approvedAt;
target.approvedBy = actor;
}
});
io.sockets.sockets.forEach((socket) => {
const data = ensureSocketData(socket);
if (normalizeCookieUserId(data.cookieUserId) === request.cookieUserId) {
reevaluateSocketVerification(socket);
}
});
publishEvent({
source: 'verification',
type: 'verification.resolved',
payload: {
requestId,
decision: 'approved',
cookieUserId: request.cookieUserId,
nickname: request.nickname,
resolvedBy: actor,
resolvedAt: approvedAt,
},
});
emitChange('approve', { requestId });
}
function denyRequest(requestId, actorDiscordId) {
const request = getPendingRequestById(requestId);
if (!request) {
throw new Error('Request not found or already resolved.');
}
const deniedAt = Date.now();
const actor = actorDiscordId ? String(actorDiscordId) : null;
withStore((draft) => {
const pending = draft.pendingRequests.find((entry) => entry.id === requestId);
if (!pending || pending.status !== 'pending') {
throw new Error('Request not found or already resolved.');
}
pending.status = 'denied';
pending.decision = 'denied';
pending.resolvedAt = deniedAt;
pending.resolvedBy = actor;
});
publishEvent({
source: 'verification',
type: 'verification.resolved',
payload: {
requestId,
decision: 'denied',
cookieUserId: request.cookieUserId,
nickname: request.nickname,
resolvedBy: actor,
resolvedAt: deniedAt,
},
});
emitChange('deny', { requestId });
}
function getVerificationStateForSocket(socket) {
const identity = getIdentitySummary(socket);
const pending = getPendingRequestForIdentity(identity.cookieUserId);
return {
isVerified: Boolean(socket?.data?.isVerified),
pendingRequestId: pending?.id || null,
pendingRequestedAt: pending?.createdAt || null,
};
}
function isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
io.on('connection', (socket) => {
identifySocket(socket, {});
socket.on('session:identify', (payload = {}, cb = () => {}) => {
try {
const result = identifySocket(socket, payload || {});
cb({ success: true, ...result });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('verification:request', (_, cb = () => {}) => {
try {
const request = createVerificationRequest(socket);
cb({ success: true, requestId: request.id, status: request.status });
} catch (err) {
cb({ error: err.message });
}
});
});
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
try {
reevaluateSocketVerification(socket);
emitChange('role_change', { socketId: socket.id });
} catch (err) {
logger.warn('Failed to reevaluate verification on role change', err.message);
}
});
module.exports = {
identifySocket,
getVerificationStatus,
getIdentitySummary,
getVerificationStateForSocket,
createVerificationRequest,
attachDmMessage,
getRequestByMessageId,
approveRequest,
denyRequest,
listVerifiedUsers,
removeVerifiedUser,
isVerified,
reevaluateSocketVerification,
verificationEvents,
};
+9 -81
View File
@@ -4,12 +4,11 @@ const logger = require('../globals/logger').child('videoAuth');
const videoSessions = require('./videoSessions'); const videoSessions = require('./videoSessions');
const { getMode, MODES } = require('./modeManager'); const { getMode, MODES } = require('./modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('./roleService'); const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const { isVerified } = require('./verificationService');
const turnService = require('./turnService');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getRequestIp, getSocketIp, isLocalNetwork } = require('../helpers/ipResolver'); const { getRequestIp } = require('../helpers/ipResolver');
const { logAdminEvent } = require('./adminLogService'); const { logAdminEvent } = require('./adminLogService');
const { isBannedSocket } = require('./moderationService');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
@@ -43,16 +42,13 @@ function extractStreamInfo(path) {
} }
let end = segments.length; let end = segments.length;
if (segments[end - 1] === 'whep' || segments[end - 1] === 'whip') { if (segments[end - 1] === 'whep') {
end -= 1; end -= 1;
} }
const remaining = segments.slice(start, end); const remaining = segments.slice(start, end);
if (remaining.length === 1) { if (remaining.length === 1) {
const rawId = remaining[0] || ''; const rawId = remaining[0] || '';
if (rawId.endsWith('-fwd')) {
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
}
const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId; const baseId = rawId.endsWith('-audio') ? rawId.slice(0, -6) : rawId;
return { type: 'rover', id: rawId, baseId }; return { type: 'rover', id: rawId, baseId };
} }
@@ -62,40 +58,6 @@ function extractStreamInfo(path) {
return null; return null;
} }
function extractSrtStreamId(rawValue) {
const value = decodeURIComponent(String(rawValue || '').trim());
if (!value) return '';
// streamid may be passed as the full value or as query text.
const match = value.match(/(?:^|[?&]|,|#!::)r=([^,&]+)/);
if (match?.[1]) {
return match[1];
}
// Fallback: treat plain token as stream id when no separators are present.
if (!/[?&=,:]/.test(value)) {
return value;
}
return '';
}
function extractStreamInfoFromBody(body = {}) {
const fromPath = extractStreamInfo((body.path || '').replace(/^\//, ''));
if (fromPath) return fromPath;
const srtId =
extractSrtStreamId(body.streamid) ||
extractSrtStreamId(body.streamId) ||
extractSrtStreamId(body.query);
if (!srtId) return null;
if (srtId.endsWith('-fwd')) {
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
}
const baseId = srtId.endsWith('-audio') ? srtId.slice(0, -6) : srtId;
return { type: 'rover', id: srtId, baseId };
}
function canView(socket) { function canView(socket) {
const mode = getMode(); const mode = getMode();
if (!socket) { if (!socket) {
@@ -118,7 +80,7 @@ app.post('/mediamtx/auth', (req, res) => {
const action = (body.action || '').toLowerCase(); const action = (body.action || '').toLowerCase();
const protocol = (body.protocol || '').toLowerCase(); const protocol = (body.protocol || '').toLowerCase();
const ip = getRequestIp(req, body.ip); const ip = getRequestIp(req, body.ip);
const streamInfo = extractStreamInfoFromBody(body); const streamInfo = extractStreamInfo(path);
logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol }); logger.info('video auth request', { path: body.path, sessionId, stream: streamInfo, action, protocol });
if (ip) { if (ip) {
@@ -130,14 +92,7 @@ app.post('/mediamtx/auth', (req, res) => {
}); });
} }
const isSrtLikeProtocol = protocol === 'srt' || protocol === 'srtconn' || protocol.startsWith('srt'); if (action === 'read' && protocol === 'srt' && streamInfo?.id) {
const isForwardAudioRead = action === 'read' && streamInfo?.id?.endsWith('-fwd');
// Rover forward-listener uses SRT read without session tokens; allow these reads.
if ((action === 'read' && isSrtLikeProtocol) || isForwardAudioRead) {
return res.status(200).end();
}
// Existing rover/media publishers use SRT without per-session tokens.
if (action === 'publish' && isSrtLikeProtocol) {
return res.status(200).end(); return res.status(200).end();
} }
@@ -147,10 +102,7 @@ app.post('/mediamtx/auth', (req, res) => {
} }
const info = videoSessions.getSession(sessionId); const info = videoSessions.getSession(sessionId);
const streamTypeMatches = if (!info || info.sourceType !== streamInfo.type || info.sourceId !== streamInfo.id) {
info &&
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id); logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
return res.status(401).end(); return res.status(401).end();
} }
@@ -159,37 +111,13 @@ app.post('/mediamtx/auth', (req, res) => {
videoSessions.revokeSession(sessionId); videoSessions.revokeSession(sessionId);
return res.status(401).end(); return res.status(401).end();
} }
if (isBannedSocket(socket)) {
return res.status(401).end();
}
if (!canView(socket)) { if (!canView(socket)) {
return res.status(401).end(); return res.status(401).end();
} }
if (streamInfo.type === 'rover') {
const roverId = streamInfo.baseId || streamInfo.id;
if (!roverManager.canSeeRover(roverId, socket)) {
return res.status(401).end();
}
}
if (info.sourceType === 'roverMic' && action === 'publish') {
const roverId = streamInfo.baseId || streamInfo.id;
if (!isVerified(socket)) {
return res.status(401).end();
}
if (!roverManager.isDriver(roverId, socket)) {
return res.status(401).end();
}
if (!turnService.canDrive(roverId, socket)) {
return res.status(401).end();
}
return res.status(200).end();
}
const role = getRole(socket); const role = getRole(socket);
const isAudio = streamInfo.id?.endsWith('-audio');
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
const socketIp = getSocketIp(socket);
if (!isLocalNetwork(socketIp)) {
return res.status(401).end();
}
}
if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) { if (streamInfo.type === 'rover' && role !== 'spectator' && !isAdmin(socket)) {
const roverId = streamInfo.baseId || streamInfo.id; const roverId = streamInfo.baseId || streamInfo.id;
if (!roverManager.isDriver(roverId, socket)) { if (!roverManager.isDriver(roverId, socket)) {
+4 -12
View File
@@ -5,7 +5,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('./roleService');
const videoSessions = require('./videoSessions'); const videoSessions = require('./videoSessions');
const roverManager = require('./roverManager'); const roverManager = require('./roverManager');
const { loadConfig } = require('../helpers/configLoader'); const { loadConfig } = require('../helpers/configLoader');
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver'); const { isBannedSocket } = require('./moderationService');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
@@ -53,9 +53,6 @@ function canViewRover(socket, roverId) {
if (!passesMode(socket)) { if (!passesMode(socket)) {
return false; return false;
} }
if (!roverManager.canSeeRover(roverId, socket)) {
return false;
}
const role = getRole(socket); const role = getRole(socket);
if (role === 'spectator' || isAdmin(socket)) { if (role === 'spectator' || isAdmin(socket)) {
return true; return true;
@@ -84,26 +81,21 @@ function normalizeRequest(payload = {}) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('video:request', (payload = {}, cb = () => {}) => { socket.on('video:request', (payload = {}, cb = () => {}) => {
try { try {
if (isBannedSocket(socket)) {
throw new Error('Banned');
}
const target = normalizeRequest(payload); const target = normalizeRequest(payload);
if (!target) { if (!target) {
throw new Error('video source required'); throw new Error('video source required');
} }
if (target.type === 'rover') { if (target.type === 'rover') {
const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id; const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id;
const isAudio = target.id.endsWith('-audio');
if (!roverManager.rovers.has(baseId)) { if (!roverManager.rovers.has(baseId)) {
throw new Error('Rover offline'); throw new Error('Rover offline');
} }
if (!canViewRover(socket, baseId)) { if (!canViewRover(socket, baseId)) {
throw new Error('Not authorized for video'); throw new Error('Not authorized for video');
} }
const role = getRole(socket);
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
const ip = getSocketIp(socket);
if (!isLocalNetwork(ip)) {
throw new Error('Not authorized for video');
}
}
} else if (target.type === 'room') { } else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed'); throw new Error('Room cameras now use the snapshot feed');
} else { } else {
+16
View File
@@ -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.
+17 -88
View File
@@ -3,15 +3,11 @@ import TelemetryPanel from './components/TelemetryPanel.jsx';
import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx'; import ReplaySourcesPanel from './components/ReplaySourcesPanel.jsx';
import AlertFeed from './components/AlertFeed.jsx'; import AlertFeed from './components/AlertFeed.jsx';
import MobileControls, { import MobileControls, {
MobileActionsColumn, MobileLandscapeAuxColumn,
MobileDriveColumn, MobileLandscapeControlColumn,
} from './components/MobileControls.jsx'; } from './components/MobileControls.jsx';
import { import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
ControlSystemProvider, import { SettingsProvider } from './settings/index.js';
KeyboardInputManager,
GamepadInputManager,
useControlSystem,
} from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx'; import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx'; import LogPanel from './components/LogPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx'; import DriverVideoPanel from './components/DriverVideoPanel.jsx';
@@ -22,7 +18,6 @@ import TurnAlertListener from './components/TurnAlertListener.jsx';
import RawUserPilePanel from './components/RawUserPilePanel.jsx'; import RawUserPilePanel from './components/RawUserPilePanel.jsx';
import ChatPanel from './components/ChatPanel.jsx'; import ChatPanel from './components/ChatPanel.jsx';
import FullscreenPrompt from './components/FullscreenPrompt.jsx'; import FullscreenPrompt from './components/FullscreenPrompt.jsx';
import FloatingFullscreenButton from './components/FloatingFullscreenButton.jsx';
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js'; import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from './settings/index.js'; import { useSettingsNamespace } from './settings/index.js';
import HelpOverlay from './components/HelpOverlay.jsx'; import HelpOverlay from './components/HelpOverlay.jsx';
@@ -30,11 +25,9 @@ import HelpPanel from './components/HelpPanel.jsx';
import SettingsPanel from './components/SettingsPanel.jsx'; import SettingsPanel from './components/SettingsPanel.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx'; import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js'; import useDefaultNickname from './hooks/useDefaultNickname.js';
import useUserIdentitySync from './hooks/useUserIdentitySync.js';
import CommunityGoalBanner from './components/CommunityGoalBanner.jsx'; import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx'; import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
import VipPanel from './components/VipPanel.jsx'; import BannedOverlay from './components/BannedOverlay.jsx';
import { useSession } from './context/SessionContext.jsx';
function useLayoutMode() { function useLayoutMode() {
const [mode, setMode] = useState(() => { const [mode, setMode] = useState(() => {
@@ -89,42 +82,11 @@ function MobileFeatureTabs({
roomPanelId, roomPanelId,
showTelemetry = true, showTelemetry = true,
}) { }) {
const { session } = useSession();
const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
session?.isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
session?.isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
);
return ( return (
<section className="panel text-base"> <section className="panel text-base">
<Tabs defaultTab="chat"> <Tabs defaultTab="chat">
<TabList> <TabList>
<Tab id="chat">Chat</Tab> <Tab id="chat">Chat</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
<span className="inline-flex items-center gap-0.5">
<span>VIP</span>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
<Tab id="roomcontrols">Room Controls</Tab> <Tab id="roomcontrols">Room Controls</Tab>
<Tab id="help">Help</Tab> <Tab id="help">Help</Tab>
<Tab id="settings">Settings</Tab> <Tab id="settings">Settings</Tab>
@@ -136,9 +98,6 @@ function MobileFeatureTabs({
<RawUserPilePanel /> <RawUserPilePanel />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="vip" keepMounted>
<VipPanel />
</TabPanel>
<TabPanel id="roomcontrols"> <TabPanel id="roomcontrols">
<div className="space-y-0.5"> <div className="space-y-0.5">
{/* {showTelemetry ? <TelemetryPanel /> : null} */} {/* {showTelemetry ? <TelemetryPanel /> : null} */}
@@ -161,11 +120,11 @@ function MobileFeatureTabs({
); );
} }
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) { function MobilePortraitLayout({ onOpenHelpOverlay }) {
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<DriverVideoPanel layoutFormat="mobile-portrait" /> <DriverVideoPanel layoutFormat="mobile-portrait" />
<MobileControls swapColumns={swapMobileControlColumns} /> <MobileControls />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]"> <div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" /> <ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
<RoverQueuesPanel /> <RoverQueuesPanel />
@@ -180,18 +139,11 @@ function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = fa
); );
} }
function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) { function MobileLandscapeLayout({ onOpenHelpOverlay }) {
const columnClass = 'self-start h-[min(100svh,32rem)]';
const firstColumn = swapMobileControlColumns
? <MobileDriveColumn layout="landscape" className={columnClass} />
: <MobileActionsColumn layout="landscape" className={columnClass} />;
const secondColumn = swapMobileControlColumns
? <MobileActionsColumn layout="landscape" className={columnClass} />
: <MobileDriveColumn layout="landscape" className={columnClass} />;
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5"> <section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
{firstColumn} <MobileLandscapeAuxColumn />
<div> <div>
<DriverVideoPanel layoutFormat="mobile-landscape" /> <DriverVideoPanel layoutFormat="mobile-landscape" />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]"> <div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
@@ -200,7 +152,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
</div> </div>
{/* <TelemetryPanel /> */} {/* <TelemetryPanel /> */}
</div> </div>
{secondColumn} <MobileLandscapeControlColumn />
</section> </section>
<div className="flex flex-col gap-0.5 pb-0"> <div className="flex flex-col gap-0.5 pb-0">
<MobileFeatureTabs <MobileFeatureTabs
@@ -221,22 +173,20 @@ function App() {
return ( return (
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}> <div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} /> <SettingsProvider>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</SettingsProvider>
</div> </div>
); );
} }
function AppWithProviders({ layout, isDesktop, fullscreen }) { function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname(); useDefaultNickname();
useUserIdentitySync();
const { const {
visible: fullscreenVisible, visible: fullscreenVisible,
mode: fullscreenMode, mode: fullscreenMode,
isIOS: fullscreenIsIOS,
nativeSupported: fullscreenNativeSupported,
enterFullscreen, enterFullscreen,
dismiss, dismiss,
showPrompt,
} = fullscreen; } = fullscreen;
const { const {
@@ -244,12 +194,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
status: helpStatus, status: helpStatus,
save: saveHelpSettings, save: saveHelpSettings,
} = useSettingsNamespace('help', { showOnLoad: true }); } = useSettingsNamespace('help', { showOnLoad: true });
const { value: pageSettings } = useSettingsNamespace('page', {
swapMobileControlColumns: false,
});
const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns);
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
const [helpVisible, setHelpVisible] = useState(false); const [helpVisible, setHelpVisible] = useState(false);
useEffect(() => { useEffect(() => {
@@ -260,16 +204,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
const openHelp = useCallback(() => setHelpVisible(true), []); const openHelp = useCallback(() => setHelpVisible(true), []);
const closeHelp = useCallback(() => setHelpVisible(false), []); const closeHelp = useCallback(() => setHelpVisible(false), []);
const handleFloatingFullscreen = useCallback(async () => {
if (fullscreenIsIOS) {
showPrompt();
return;
}
const entered = await enterFullscreen();
if (!entered) {
showPrompt();
}
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
const setShowOnLoad = useCallback( const setShowOnLoad = useCallback(
(enabled) => { (enabled) => {
const next = Boolean(enabled); const next = Boolean(enabled);
@@ -286,9 +220,9 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
isDesktop isDesktop
? <DesktopLayout layout={layout} onOpenHelpOverlay={openHelp} /> ? <DesktopLayout layout={layout} onOpenHelpOverlay={openHelp} />
: layout === 'mobile-landscape' : layout === 'mobile-landscape'
? <MobileLandscapeLayout onOpenHelpOverlay={openHelp} swapMobileControlColumns={swapMobileControlColumns} /> ? <MobileLandscapeLayout onOpenHelpOverlay={openHelp} />
: <MobilePortraitLayout onOpenHelpOverlay={openHelp} swapMobileControlColumns={swapMobileControlColumns} />, : <MobilePortraitLayout onOpenHelpOverlay={openHelp} />,
[isDesktop, layout, openHelp, swapMobileControlColumns], [isDesktop, layout, openHelp],
); );
return ( return (
@@ -302,6 +236,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
<AlertFeed /> <AlertFeed />
<TurnAlertListener /> <TurnAlertListener />
<ModeGateOverlay /> <ModeGateOverlay />
<BannedOverlay />
<HelpOverlay <HelpOverlay
visible={helpVisible} visible={helpVisible}
layout={layout} layout={layout}
@@ -315,12 +250,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
onEnterFullscreen={enterFullscreen} onEnterFullscreen={enterFullscreen}
onDismiss={dismiss} onDismiss={dismiss}
/> />
{showFloatingFullscreenButton ? (
<FloatingFullscreenButton
side={fullscreenButtonSide}
onClick={handleFloatingFullscreen}
/>
) : null}
</ControlSystemProvider> </ControlSystemProvider>
); );
} }

Some files were not shown because too many files have changed in this diff Show More