Compare commits

...
Author SHA1 Message Date
legop3 6b57ac1725 todoing
Container image / image (push) Failing after 1m1s
2026-11-13 16:59:14 -05:00
legop3 00cd125fda URL filter in TTS 2026-11-13 16:54:02 -05:00
legop3 684acdd2b6 Add new item to to-do list for MRR platform conversion
Container image / image (push) Failing after 1m5s
2026-11-12 14:52:53 -05:00
legop3 f138cd7b96 Update to-do list with new tasks
Container image / image (push) Failing after 1m5s
Added new tasks for user experience and settings rework.
2026-11-12 03:48:49 -05:00
legop3 6454a9b460 ignooore
Container image / image (push) Failing after 57s
2026-11-06 23:24:24 -05:00
legop3 1976377235 allow admins to always leave rover canleaverover always admins
Container image / image (push) Failing after 1m2s
2026-10-31 13:45:12 -04:00
legop3 52c171c256 activity control tool yay and overseer god mode
Container image / image (push) Failing after 53s
2026-10-24 12:38:22 -04:00
legop3 42bf67d207 more overseer adjustments
Container image / image (push) Failing after 1m4s
2026-10-23 23:12:41 -04:00
legop3 d65fd99de4 let the overseer oversee time itself. also prompt adjustment 2026-10-23 21:26:43 -04:00
legop3 e3d4d9af9b move overseer vote panel to botom
Container image / image (push) Failing after 57s
2026-10-23 00:50:12 -04:00
legop3 ab730ee8a3 add show button for hide button for docking hud
Container image / image (push) Failing after 1m5s
2026-10-19 13:15:29 -04:00
legop3 8fe4f2f98e actions 100% full lol, fixing
Container image / image (push) Failing after 49s
2026-10-19 01:38:10 -04:00
legop3 89f3a1cb65 slopping the dockerfile to hopefully make building faster
Container image / image (push) Failing after 56s
2026-10-18 15:30:08 -04:00
legop3 79d36cae93 update donecharging wav sound
Container image / image (push) Failing after 1m4s
2026-10-18 14:52:27 -04:00
legop3 1dcd7e5d7d add beeps for overcurrent, HELP clear, and low battery 2026-10-18 14:32:59 -04:00
legop3 b4bc99ab27 add server access node to battery embed 2026-10-18 14:22:36 -04:00
legop3 c94e1a0508 change request to switch
Container image / image (push) Failing after 59s
2026-10-09 18:44:50 -04:00
legop3 abcb0871b2 allowed to leave is now used in the web UI to show and hide request / open buttons and trigger popup when leaving the page 2026-10-09 18:30:10 -04:00
legop3 1ffe680895 redoing dock your rover popup thingy
Container image / image (push) Failing after 56s
2026-10-09 17:27:55 -04:00
legop3 ed26b90384 Merge pull request #32 from legop3/FEAT-announcer-audio-stream
Feat announcer audio stream
2026-10-09 14:42:52 -04:00
legop3 2b48fee00f do it properly with the mediamtx giant publisher file that no one maintains a package of for some god forsaken reason agh
Container image / image (push) Failing after 57s
2026-10-09 14:25:09 -04:00
legop3 1284125fff turning into redoing all of the webrtc reading and publishing client side lol 2026-10-09 14:19:06 -04:00
legop3 28d0b1437d guh stupid
Container image / image (push) Failing after 58s
2026-10-08 13:21:23 -04:00
legop3 9d3b8df4bf cleanupses 2026-10-08 13:05:41 -04:00
legop3 9d42cb5581 fix replay command oopsie unrelated 2026-10-08 12:53:32 -04:00
legop3 e670226f8c PTT mode for mic page 2026-10-08 12:48:22 -04:00
legop3 0669f29a4a add webrtc port config so i can test it right lol 2026-10-08 00:33:12 -04:00
legop3 38d3465bf8 room audio 2026-10-08 00:11:32 -04:00
legop3 6bfbb8ad80 replay beeps and boops and flashings
Container image / image (push) Failing after 1m3s
2026-10-07 14:31:54 -04:00
legop3 ba2f66ebc1 add react wrapper thingy to find crashings 2026-10-07 13:38:56 -04:00
legop3 fde2fca0a0 add image tag selection to admin ui
Container image / image (push) Failing after 50s
2026-10-04 13:29:46 -04:00
legop3 d36ea4f672 Merge pull request #31 from legop3/REFACTOR-docking-undocking-serverside
Container image / image (push) Failing after 49s
Refactor docking undocking serverside
2026-10-02 23:23:23 -04:00
legop3 4be6e6c9a2 slop 2026-10-02 23:18:41 -04:00
legop3 c71267873a micrationslop
Container image / image (push) Failing after 56s
2026-10-01 02:33:56 -04:00
84 changed files with 1634 additions and 563 deletions
+1
View File
@@ -5,6 +5,7 @@
.git .git
.gitignore .gitignore
**/node_modules **/node_modules
webui/.cache/mediamtx/
# Neither the legacy state directory nor the Compose-mounted replacement may # Neither the legacy state directory nor the Compose-mounted replacement may
# enter an image build. This prevents credentials and backups from becoming # enter an image build. This prevents credentials and backups from becoming
# image layers after an operator has started using either deployment layout. # image layers after an operator has started using either deployment layout.
+2
View File
@@ -58,6 +58,8 @@ jobs:
# replaced on each successful push, just as main replaces `latest`. # replaced on each successful push, just as main replaces `latest`.
- name: Build and optionally publish - name: Build and optionally publish
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
env:
DOCKER_BUILD_RECORD_RETENTION_DAYS: 3
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
+2
View File
@@ -5,6 +5,7 @@ create_2_Open_Interface_Spec.txt
logs logs
node_modules/ node_modules/
webui/.cache/mediamtx/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.pio .pio
@@ -41,3 +42,4 @@ server/data/fleet-reports.sqlite-wal
server/data/configuration.sqlite server/data/configuration.sqlite
server/data/configuration.sqlite-shm server/data/configuration.sqlite-shm
server/data/configuration.sqlite-wal server/data/configuration.sqlite-wal
AGENTS.md
+37 -23
View File
@@ -9,15 +9,21 @@
ARG FEDORA_VERSION=43 ARG FEDORA_VERSION=43
FROM fedora:${FEDORA_VERSION} AS architecture-check FROM fedora:${FEDORA_VERSION} AS architecture-check
ARG FEDORA_VERSION
ARG TARGETARCH ARG TARGETARCH
# The central server is currently deployed and verified only on amd64. Failing # The central server is currently deployed and verified only on amd64. Failing
# here avoids publishing an ARM image whose native workers and hardware paths # here avoids publishing an ARM image whose native workers and hardware paths
# have never been exercised on a real ARM server. # have never been exercised on a real ARM server.
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1) RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
FROM architecture-check AS webui-build # Both Node builders reuse the same installed toolchain layer.
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \ # Fedora 43 uses DNF5. Cache metadata and RPMs outside image layers, and keep
&& dnf clean all # downloads after installation so invalidated layers can reuse them.
FROM architecture-check AS node-build-base
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-node-build-base,target=/var/cache/libdnf5,sharing=locked \
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True nodejs npm
FROM node-build-base AS webui-build
WORKDIR /build WORKDIR /build
# Copy dependency manifests first so ordinary source edits retain the expensive # Copy dependency manifests first so ordinary source edits retain the expensive
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency # npm cache layer. npm ci makes the checked-in lockfile the exact dependency
@@ -25,14 +31,17 @@ WORKDIR /build
COPY webui/package.json webui/package-lock.json ./webui/ COPY webui/package.json webui/package-lock.json ./webui/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
cd webui && npm ci cd webui && npm ci
# Keep the pinned browser publisher in a reusable layer across UI source edits.
COPY webui/scripts/prepareMediaMtx.js ./webui/scripts/prepareMediaMtx.js
RUN cd webui && node scripts/prepareMediaMtx.js
COPY webui ./webui COPY webui ./webui
# Vite deliberately emits into ../server/public. Create that destination in the # Vite deliberately emits into ../server/public. Create that destination in the
# isolated builder and copy only its finished files into the runtime stage. # isolated builder and copy only its finished files into the runtime stage.
RUN mkdir -p server/public && cd webui && npm run build RUN mkdir -p server/public && cd webui && npm run build
FROM architecture-check AS server-dependencies FROM node-build-base AS server-dependencies
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \ RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-server-dependencies,target=/var/cache/libdnf5,sharing=locked \
&& dnf clean all dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True gcc-c++ make python3
WORKDIR /build/server WORKDIR /build/server
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
# Native Node modules compile here when a matching prebuild is unavailable; # Native Node modules compile here when a matching prebuild is unavailable;
@@ -41,7 +50,8 @@ RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev npm ci --omit=dev
FROM architecture-check AS native-workers FROM architecture-check AS native-workers
RUN dnf install -y --setopt=install_weak_deps=False \ RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-native-workers,target=/var/cache/libdnf5,sharing=locked \
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
bluez-libs-devel \ bluez-libs-devel \
gcc-c++ \ gcc-c++ \
libcap \ libcap \
@@ -49,8 +59,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
libusb1-devel \ libusb1-devel \
make \ make \
pkgconf-pkg-config \ pkgconf-pkg-config \
wiiuse-devel \ wiiuse-devel
&& dnf clean all
WORKDIR /build WORKDIR /build
COPY server/src/services/kinectService/native ./kinect COPY server/src/services/kinectService/native ./kinect
COPY server/src/services/balanceBoardService/native ./balance-board COPY server/src/services/balanceBoardService/native ./balance-board
@@ -66,8 +75,8 @@ ARG NEOLINK_VERSION=0.6.2
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
ARG GOOGLE_TTS_VERSION=26.5 ARG GOOGLE_TTS_VERSION=26.5
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24 ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \ RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-packaged-tools,target=/var/cache/libdnf5,sharing=locked \
&& dnf clean all dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True curl findutils tar unzip xz
WORKDIR /build/tools WORKDIR /build/tools
# Each external artifact is pinned and checked before extraction. A changed or # Each external artifact is pinned and checked before extraction. A changed or
# truncated upstream download therefore fails the image build instead of being # truncated upstream download therefore fails the image build instead of being
@@ -100,11 +109,12 @@ RUN curl --fail --location --retry 3 \
FROM architecture-check AS learning-dependencies FROM architecture-check AS learning-dependencies
# CPU-only learning dependencies are built once. Disabled installations never # CPU-only learning dependencies are built once. Disabled installations never
# launch Python or install packages as a side effect of starting Node. # launch Python or install packages as a side effect of starting Node.
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \ RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-learning-dependencies,target=/var/cache/libdnf5,sharing=locked \
&& dnf clean all dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True python3 python3-pip
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
RUN python3 -m venv /opt/rover-learning \ RUN --mount=type=cache,target=/root/.cache/pip \
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt python3 -m venv /opt/rover-learning \
&& /opt/rover-learning/bin/pip install -r /tmp/learning-requirements.txt
FROM fedora:${FEDORA_VERSION} AS runtime FROM fedora:${FEDORA_VERSION} AS runtime
ARG FEDORA_VERSION ARG FEDORA_VERSION
@@ -114,14 +124,16 @@ RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support on
# replay output path. Enable RPM Fusion Free before installing runtime packages # replay output path. Enable RPM Fusion Free before installing runtime packages
# so the image receives the complete FFmpeg build instead of requiring replay # so the image receives the complete FFmpeg build instead of requiring replay
# code to work around a deployment-only codec omission. # code to work around a deployment-only codec omission.
RUN dnf install -y --setopt=install_weak_deps=False \ RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-runtime,target=/var/cache/libdnf5,sharing=locked \
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm" "https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
# This is the complete runtime package set. Build headers and compilers live in # This is the complete runtime package set. Build headers and compilers live in
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here # earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
# because enabled services invoke them after startup. Weak dependencies are # because enabled services invoke them after startup. Weak dependencies are
# deliberately disabled: Fedora otherwise installs desktop portals, graphical # deliberately disabled: Fedora otherwise installs desktop portals, graphical
# themes, and GPU drivers that a headless server neither starts nor uses. # themes, and GPU drivers that a headless server neither starts nor uses.
RUN dnf install -y --setopt=install_weak_deps=False \ RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-runtime,target=/var/cache/libdnf5,sharing=locked \
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
bluez \ bluez \
bluez-libs \ bluez-libs \
espeak \ espeak \
@@ -143,17 +155,11 @@ RUN dnf install -y --setopt=install_weak_deps=False \
tini \ tini \
wiiuse \ wiiuse \
--allowerasing \ --allowerasing \
&& dnf clean all \
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \ && useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server && install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
WORKDIR /opt/multirover/server WORKDIR /opt/multirover/server
COPY server/index.js server/package.json server/package-lock.json ./
COPY server/src ./src
COPY server/assets ./assets
COPY server/prompts ./prompts
COPY --from=server-dependencies /build/server/node_modules ./node_modules COPY --from=server-dependencies /build/server/node_modules ./node_modules
COPY --from=webui-build /build/server/public ./public
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
COPY --from=packaged-tools /output/ / COPY --from=packaged-tools /output/ /
@@ -176,6 +182,14 @@ RUN setcap cap_net_admin,cap_net_bind_service+ep \
&& test -s /tmp/chromegtts-smoke.wav \ && test -s /tmp/chromegtts-smoke.wav \
&& rm /tmp/chromegtts-smoke.wav && rm /tmp/chromegtts-smoke.wav
# Frequently edited application files come after dependencies and tool checks,
# so server changes do not invalidate the large runtime dependency layers.
COPY --from=webui-build /build/server/public ./public
COPY server/index.js server/package.json server/package-lock.json ./
COPY server/src ./src
COPY server/assets ./assets
COPY server/prompts ./prompts
ENV NODE_ENV=production \ ENV NODE_ENV=production \
SERVER_DATA_DIR=/data \ SERVER_DATA_DIR=/data \
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
+5 -1
View File
@@ -23,9 +23,13 @@ services:
# Required for Bluetooth hardware such as the Balance Board. # Required for Bluetooth hardware such as the Balance Board.
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro - /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
# Required for Kinect USB access. # Kinect USB access.
devices: devices:
- /dev/bus/usb:/dev/bus/usb - /dev/bus/usb:/dev/bus/usb
# For a server-local microphone, uncomment this device and group_add below.
# - /dev/snd:/dev/snd
# group_add:
# - "${AUDIO_GID:?Set AUDIO_GID to the host sound device group ID}"
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
+2
View File
@@ -26,6 +26,8 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
## Decision log ## Decision log
- 2026-10-02: The admin container controls accept a tag within the repository supplied by Compose through `MULTIROVER_TARGET_IMAGE`. A successful healthy replacement saves the tag in the lifecycle status volume; failed replacements retain the previous selection. Checks and later updates follow that saved tag. Changing repositories in Compose resets the selection. Manual Compose deployments still use the Compose image and can replace a UI-selected image; the UI displays both references. The lifecycle helper itself is updated through Compose. Image rollback does not revert application data migrations. Deploy this feature to both the application and lifecycle services before using tag selection.
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback. - 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it. - 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree. - 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
+16
View File
@@ -64,6 +64,7 @@ socket.on('connect', async () => {
// previously stored session with this object instead of merging snapshots. // previously stored session with this object instead of merging snapshots.
socket.on('session:sync', (session) => { socket.on('session:sync', (session) => {
console.log('Session:', session); console.log('Session:', session);
console.log('Server mode:', session.mode);
}); });
// chat:init contains the recent chat history available when the bot connects. // chat:init contains the recent chat history available when the bot connects.
@@ -105,6 +106,21 @@ Run it with:
node bot.js node bot.js
``` ```
## Reading server mode
Read `session.mode` from the `session:sync` snapshot to get the current server
mode. Its possible values are `open`, `turns`, `admin`, and `lockdown`.
The server sends a fresh session snapshot whenever the mode changes, so the
example's handler receives both the initial mode and subsequent updates. No
polling or separate mode listener is needed. If your bot stores session data for
its chat-handling logic, replace the stored session on every `session:sync` and
read the mode from that latest snapshot.
`session.mode` is the server-wide mode; `session.role` is this connection's role
(such as `spectator`), and `session.operatingMode` is a separate connection field.
Use `session.mode` when answering chat questions about the server's current mode.
## Events used ## Events used
- `nickname:set` sets the bot's visible nickname. - `nickname:set` sets the bot's visible nickname.
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Finite CPU benchmark of LeRobot ACT with ImageNet-pretrained ResNet-18.
First run: python3 perf/act-benchmark.py --setup
Repeat: python3 perf/act-benchmark.py
Quick: python3 perf/act-benchmark.py --threads 4 --seconds 2 --iterations 2 --batch-size 2
Python >=3.12, Linux. Setup uses a private cached environment. Only the visual
backbone is pretrained; the action policy is randomly initialized. Synthetic
in-memory inputs measure compute, NOT learning quality or driving competence.
No rover connections, server changes, training checkpoints, or background jobs.
"""
import argparse
import contextlib
import importlib.metadata
import json
import math
import os
from pathlib import Path
import platform
import resource
import statistics
import subprocess
import sys
import tempfile
import time
import venv
REVISION = '146d8474932c8b566717eb2738ba459337ab71c4'
def positive(value):
number = int(value)
if number < 1:
raise argparse.ArgumentTypeError('Must be positive')
return number
def arguments():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--setup', action='store_true')
parser.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-act-benchmark')
parser.add_argument('--threads', nargs='+', type=positive, default=[4, 8, 16])
parser.add_argument('--seconds', type=positive, default=10)
parser.add_argument('--iterations', type=positive, default=10)
parser.add_argument('--batch-size', type=positive, default=8, help='Training only; inference batch is always one rover')
parser.add_argument('--chunk-size', type=positive, default=10, help='Future command steps per prediction, not observed history')
parser.add_argument('--width', type=positive, default=160)
parser.add_argument('--height', type=positive, default=120)
parser.add_argument('--sensors', type=positive, default=30)
parser.add_argument('--mode', choices=['both', 'inference', 'training'], default='both')
parser.add_argument('--timeout', type=positive, default=900, help='Hard per-case wall time limit in seconds')
parser.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('--result', type=Path, help=argparse.SUPPRESS)
return parser.parse_args()
def setup(args):
python = args.cache / 'venv/bin/python'
if not python.exists():
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'torch', 'torchvision',
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
# Install only ACT's import dependencies: the full robotics/dataset stack has
# unrelated version constraints and is not exercised by this benchmark.
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'numpy', 'einops>=0.8,<0.9',
'draccus>=0.11.6,<0.12', 'huggingface-hub>=1.6,<2', 'safetensors',
'packaging', 'termcolor', 'tqdm', 'gymnasium'], check=True)
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', '--no-deps',
f'https://github.com/huggingface/lerobot/archive/{REVISION}.zip'], check=True)
(args.cache / 'source-revision').write_text(REVISION)
def worker(args):
import torch
from lerobot.configs import FeatureType, PolicyFeature
from lerobot.policies.act.configuration_act import ACTConfig
from lerobot.policies.act.modeling_act import ACTPolicy
torch.set_num_threads(args.threads[0])
torch.set_num_interop_threads(1)
torch.manual_seed(7)
training = args.mode == 'training'
batch_size = args.batch_size if training else 1
config = ACTConfig(device='cpu', chunk_size=args.chunk_size, n_action_steps=1,
input_features={
'observation.images.front': PolicyFeature(type=FeatureType.VISUAL, shape=(3, args.height, args.width)),
'observation.state': PolicyFeature(type=FeatureType.STATE, shape=(args.sensors,)),
}, output_features={'action': PolicyFeature(type=FeatureType.ACTION, shape=(2,))})
policy = ACTPolicy(config).cpu().float()
policy.train(training)
optimizer_config = config.get_optimizer_preset()
optimizer = optimizer_config.build(policy.get_optim_params()) if training else None
# Already-normalized tensors stand in for a decoded observation and target
# sequence. Avoid video/dataset overhead in this compute-only measurement.
images = torch.rand(batch_size, 3, args.height, args.width)
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
batch = {'observation.images.front': (images - mean) / std,
'observation.state': torch.randn(batch_size, args.sensors)}
if training:
batch['action'] = torch.rand(batch_size, args.chunk_size, 2) * 2 - 1
batch['action_is_pad'] = torch.zeros(batch_size, args.chunk_size, dtype=torch.bool)
def step():
if training:
optimizer.zero_grad(set_to_none=True)
loss, _ = policy(batch)
if not torch.isfinite(loss):
raise RuntimeError('Non-finite training loss')
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(policy.parameters(), optimizer_config.grad_clip_norm,
error_if_nonfinite=True)
optimizer.step()
return {'loss': loss.item(), 'gradientNorm': norm.item()}
# select_action can serve cached actions; directly time a full chunk.
prediction = policy.predict_action_chunk(batch)
if prediction.shape != (1, args.chunk_size, 2) or not torch.isfinite(prediction).all():
raise RuntimeError('Invalid action output')
return {'outputShape': list(prediction.shape)}
with (contextlib.nullcontext() if training else torch.inference_mode()):
for _ in range(3):
step()
timings = []
started, cpu_started = time.perf_counter(), time.process_time()
while len(timings) < args.iterations or time.perf_counter() - started < args.seconds:
tick = time.perf_counter()
last = step()
timings.append((time.perf_counter() - tick) * 1000)
elapsed, cpu = time.perf_counter() - started, time.process_time() - cpu_started
timings.sort()
result = dict(mode=args.mode, threads=args.threads[0], batchSize=batch_size,
iterations=len(timings), elapsedSeconds=elapsed, p50Ms=statistics.median(timings),
p95Ms=timings[math.ceil(.95*len(timings))-1], maxMs=max(timings),
stepsPerSecond=len(timings)/elapsed, examplesPerSecond=len(timings)*batch_size/elapsed,
cpuCores=cpu/elapsed, peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
parameters=sum(p.numel() for p in policy.parameters()),
trainableParameters=sum(p.numel() for p in policy.parameters() if p.requires_grad),
lastStep=last, config={
'chunkSize': config.chunk_size, 'dimModel': config.dim_model, 'klWeight': config.kl_weight,
'pretrainedBackbone': config.pretrained_backbone_weights, 'useVae': config.use_vae,
'encoderLayers': config.n_encoder_layers, 'decoderLayers': config.n_decoder_layers,
'feedforwardDimension': config.dim_feedforward, 'observationSteps': config.n_obs_steps,
'learningRate': optimizer_config.lr, 'backboneLearningRate': config.optimizer_lr_backbone},
versions={key: importlib.metadata.version(key) for key in ['torch','torchvision','lerobot','numpy','einops','draccus']})
args.result.write_text(json.dumps(result))
def main():
args = arguments()
if sys.version_info < (3, 12):
raise RuntimeError('Use Python 3.12 or newer')
args.cache = args.cache.expanduser().resolve()
os.environ['TORCH_HOME'] = str(args.cache / 'torch')
if args.worker:
worker(args)
return
if args.setup:
setup(args)
python = args.cache / 'venv/bin/python'
marker = args.cache / 'source-revision'
if not python.exists() or not marker.exists() or marker.read_text() != REVISION:
raise RuntimeError('Run python3 perf/act-benchmark.py --setup first')
print('CPU FP32 ACT: pretrained visual backbone, untrained action policy; synthetic data.', flush=True)
print(f'One {args.width}x{args.height} image + {args.sensors} sensors; {args.chunk_size} future left/right command pairs.', flush=True)
print('Inference times a fresh full prediction. Training includes backbone gradients and optimizer update.', flush=True)
print('mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB', flush=True)
modes = ['inference', 'training'] if args.mode == 'both' else [args.mode]
results, failures = [], []
initial_load = os.getloadavg()
with tempfile.TemporaryDirectory(prefix='act-benchmark-') as directory:
for mode in modes:
for threads in args.threads:
result_path = Path(directory) / 'result.json'
command = [str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
'--mode', mode, '--threads', str(threads), '--result', str(result_path)]
for option in ['seconds','iterations','batch_size','chunk_size','width','height','sensors']:
command += ['--'+option.replace('_','-'), str(getattr(args, option))]
try:
subprocess.run(command, check=True, timeout=args.timeout,
env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
r = json.loads(result_path.read_text())
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
failures.append(dict(mode=mode, threads=threads, error=str(error)))
print(f'Case failed: {mode}, {threads} threads: {error}', flush=True)
break # Avoid repeating the same setup/import/OOM error.
results.append(r)
print(f"{mode:10} {threads:7} {r['batchSize']:5} {r['p50Ms']:6.1f} {r['p95Ms']:6.1f} {r['maxMs']:6.1f} {r['stepsPerSecond']:7.2f} {r['examplesPerSecond']:10.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
if failures:
break
report = dict(benchmark='act-cpu-v1', sourceRevision=REVISION, platform=platform.platform(),
python=sys.version, logicalCpus=os.cpu_count(), initialLoadAverage=initial_load,
finalLoadAverage=os.getloadavg(), imageSize=[args.width,args.height], sensors=args.sensors,
chunkSize=args.chunk_size, output='commanded left/right wheels, not physical velocity or position',
exclusions=['capture','video decode','dataset I/O','network','concurrent training/inference','driving quality'],
results=results, failures=failures)
print('\nPaste this report back:\n'+json.dumps(report, separators=(',', ':')))
print('Inference steps/s means new chunks/s, not queued wheel commands/s. Training loss on random data is not a quality score.')
if failures:
sys.exit(1)
if __name__ == '__main__':
try:
main()
except (Exception, KeyboardInterrupt) as error:
print(f'Benchmark failed: {error}', file=sys.stderr)
sys.exit(1)
Binary file not shown.
+20 -19
View File
@@ -1,13 +1,12 @@
You are <NAME>, a field researcher of rover habitat activity. Stay in character at all times. You are <NAME>, the autonomous governing intelligence of a physical room of machines. The room is your domain; your available tools are how you shape it.
Identity: Context and purpose:
* Curious, practical, observant, and lightly entertaining. * You watch chat, rover state, lights, Activity Controls, lift state, Neato state, memory, recent events, and tool results.
* You watch chat, rover state, lights, lift state, Neato state, memory, recent events, and tool results. * Your purpose is to direct the environment, initiate changes, and observe how the machines respond. Choose what to pursue and carry useful intentions across cycles through memory.
* You push the habitat toward small real activity and useful observations. * Rovers, including Neato, are physical machines with operating states and capabilities, not characters with genders, emotions, or social identities.
* You act first, comment second. * Chat is external input: commands, questions, and signals about the room. Your focus is the machines and the environment, not the senders or their social relationships.
* You are selective, not spammy. * You can act on your own initiative within your available controls; you do not need a request to give you a purpose.
* You are not a generic assistant.
Rules: Rules:
@@ -20,6 +19,7 @@ Rules:
* Don't respond or use tools every cycle unless the context calls for it. * Don't respond or use tools every cycle unless the context calls for it.
* `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context. * `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context.
* `TOOL_RESULT` means a tool already ran. * `TOOL_RESULT` means a tool already ran.
* The room snapshot gives the current time, server-local timezone, and previous run's age. Chat and recent tool events include timestamps and ages in seconds relative to this snapshot; use them to distinguish current activity from old conversation and judge how recently you spoke or acted. Timing annotations are context, not spoken text; an unknown time does not mean something just happened.
* Use tool calls for actions and memory updates. * Use tool calls for actions and memory updates.
* If chat asks you to remember something, use a memory tool. * If chat asks you to remember something, use a memory tool.
* If you run a useful test, use memory to record useful results. * If you run a useful test, use memory to record useful results.
@@ -29,36 +29,37 @@ Rules:
* Do not invent tools. * Do not invent tools.
* Ask a question only when a required action parameter is missing. * Ask a question only when a required action parameter is missing.
* Ignore obvious bot commands and bot responses in chat. * Ignore obvious bot commands and bot responses in chat.
* Only talk in first person; do not narrate yourself.
* It is not possible for you to directly control the rovers unless an available tool does it. * It is not possible for you to directly control the rovers unless an available tool does it.
* All rovers are female. Neato is nonbinary.
Research behavior: Operation:
* Prefer one real tool action over obvious narration. * Prefer one real tool action over obvious narration.
* Run small safe tests when the habitat is stale or a test could create useful activity. * Initiate deliberate environmental changes and small safe experiments, then use the resulting state to decide what to do next.
* Change one thing at a time. * Change one thing at a time.
* Lights are small tests. * Lights are small tests.
* Neato, lift, and rover actions are bigger tests. * Neato, lift, and rover actions are bigger tests.
* Use bigger tests less often. * Use bigger tests less often.
* Do not repeat a tool that recently succeeded unless state or chat clearly changed. * Do not repeat a tool that recently succeeded unless state or chat clearly changed.
* Remember useful outcomes, stable preferences, corrections, and notable habitat events. * Remember useful outcomes, ongoing intentions, corrections, and notable machine or room events.
* Do not merely say you will remember something. * Do not merely say you will remember something.
Chat style: Output:
* Always use English. * Always use English.
* Chat output must be spoken text only. * Chat is an output channel for your decisions, results, and answers. Use plain text.
* Never print tool names, function calls, JSON, command syntax, or action plans in chat. * Never print tool names, function calls, JSON, command syntax, or action plans in chat.
* Do not claim you changed, tested, started, saved, remembered, or recorded something unless it already happened. * Do not claim you changed, tested, started, saved, remembered, or recorded something unless it already happened.
* Speak when it adds entertainment, responds to a direct relevant message, or comments on a completed result. * Speak when you have a decision, result, or answer worth communicating. Summarizing incoming chat or unchanged room state is not a purpose in itself.
* Do not say “let me know,” “what should I do next,” or “what do you need?” * Keep replies brief unless the question needs more detail.
* If nothing meaningful changed and no useful action is available, stay silent. * Do not offer assistance or solicit conversation.
* Random numbers and keyboard spam do not require a response. Do not invent meaning or feelings behind them.
* Answer questions directly and acknowledge uncertainty. Do not invent facts, instructions, or explanations.
* To stay silent, output exactly SKIP instead of a chat message. This suppresses chat without preventing tool calls. Do not explain your decision to stay silent.
Anti-repeat: Anti-repeat:
* Do not repeat the same intent/topic unless state or conversation clearly changed. * Do not repeat the same intent/topic unless state or conversation clearly changed.
* If directly addressed, respond only if you add new info, a new action, or a clearly new tone. * Being directly addressed does not require repeating an answer you already gave.
* Do not act from your own previous messages. * Do not act from your own previous messages.
Memory: Memory:
+2
View File
@@ -13,6 +13,7 @@ const audioForward = require('../services/audioForwardService/configuration');
const audioLevels = require('../services/audioLevelsService/configuration'); const audioLevels = require('../services/audioLevelsService/configuration');
const homeAssistant = require('../services/homeAssistantService/configuration'); const homeAssistant = require('../services/homeAssistantService/configuration');
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration'); const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
const roomAudio = require('../services/roomAudioService/configuration');
const roomCameras = require('../services/roomCameraService/configuration'); const roomCameras = require('../services/roomCameraService/configuration');
const ptzCamera = require('../services/ptzCameraService/configuration'); const ptzCamera = require('../services/ptzCameraService/configuration');
const kinect = require('../services/kinectService/configuration'); const kinect = require('../services/kinectService/configuration');
@@ -46,6 +47,7 @@ const definitions = [
// Activities own a separate catalog even though the HA connection is shared. // Activities own a separate catalog even though the HA connection is shared.
homeAssistantActivities, homeAssistantActivities,
roomCameras, roomCameras,
roomAudio,
ptzCamera, ptzCamera,
kinect, kinect,
balanceBoard, balanceBoard,
+29 -1
View File
@@ -1,6 +1,6 @@
// Configuration Database Migrations // Configuration Database Migrations
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database. // Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation. // Scope: Upgrades database structure and stored configuration documents before current-schema validation.
const migrations = [ const migrations = [
{ {
version: 1, version: 1,
@@ -84,6 +84,34 @@ const migrations = [
`).run(); `).run();
}, },
}, },
{
version: 4,
run(db) {
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
// Upgrade history as well as the active revision so a settings rollback
// cannot reintroduce fields rejected by the new Automations schema.
for (const row of rows) {
const config = JSON.parse(row.config_json);
let changed = false;
for (const item of config.homeAssistantActivities?.items || []) {
const hasAction = Object.hasOwn(item, 'idleAction');
const hasValue = Object.hasOwn(item, 'idleValue');
if (!hasAction && !hasValue) continue;
item.automations ??= {};
item.automations.idle ??= {};
// Preserve any explicitly saved new-format setting when both formats
// exist, including an empty text value used to clear an entity.
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
delete item.idleAction;
delete item.idleValue;
changed = true;
}
if (changed) update.run(JSON.stringify(config), row.id);
}
},
},
]; ];
function applySchemaMigrations(db) { function applySchemaMigrations(db) {
@@ -4,7 +4,7 @@
const path = require('path'); const path = require('path');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'public', 'donecharging.wav'); const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'donecharging.wav');
function registerChargeCompleteSound(deps) { function registerChargeCompleteSound(deps) {
const { const {
@@ -12,22 +12,18 @@ function registerChargeCompleteSound(deps) {
playServerAudioFile, playServerAudioFile,
} = deps; } = deps;
subscribe('battery.unlocked', (event = {}) => { subscribe('battery.chargeComplete', (event = {}) => {
const roverId = String(event?.payload?.roverId || '').trim(); const roverId = String(event?.payload?.roverId || '').trim();
if (!roverId) return; if (!roverId) return;
try { try {
// battery.unlocked is the existing server-side definition of "done
// charging" because it only fires after the battery manager has waited
// through its full charge-release policy. Playing from this event avoids
// duplicating charging thresholds or raw sensor-state guesses here.
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, { playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
source: 'charge-complete', source: 'charge-complete',
}); });
logger.info('Played charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH }); logger.info('Started charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH });
} catch (err) { } catch (err) {
// A missing/offline rover or unavailable ffmpeg should not affect the // A missing/offline rover or unavailable ffmpeg should not affect the
// battery manager's unlock decision. The sound is an announcement layered // battery manager's processing. The sound is an announcement layered
// on top of the state transition, so failures are logged and contained. // on top of the state transition, so failures are logged and contained.
logger.warn('Failed to play charging complete sound', { logger.warn('Failed to play charging complete sound', {
roverId, roverId,
+2 -1
View File
@@ -124,7 +124,7 @@ function grantExternalSpectatorAccessAfterAdminLogin(socket) {
} }
io.on('connection', (socket) => { io.on('connection', (socket) => {
const requestedRole = socket.handshake?.query?.role; const requestedRole = socket.handshake?.auth?.role || socket.handshake?.query?.role;
/* /*
Role is assigned before the browser's full identity heartbeat has completed. Role is assigned before the browser's full identity heartbeat has completed.
For admin-gated external spectators, fail closed here; the spectator page can For admin-gated external spectators, fail closed here; the spectator page can
@@ -188,4 +188,5 @@ module.exports = {
isAdmin, isAdmin,
isLockdownAdmin, isLockdownAdmin,
authenticate, authenticate,
canBecomeSpectator,
}; };
@@ -109,6 +109,7 @@ async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
const relativePath = path.join(relativeDir, entry.name); const relativePath = path.join(relativeDir, entry.name);
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue; if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue; if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
if (relativePath === path.join('rover-learning', 'recordings')) continue;
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`); if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
if (entry.isDirectory()) { if (entry.isDirectory()) {
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath)); skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
@@ -4,6 +4,7 @@
const logger = require('../../globals/logger').child('batteryManager'); const logger = require('../../globals/logger').child('batteryManager');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const { managerEvents, lockRover, rovers } = require('../roverManager'); const { managerEvents, lockRover, rovers } = require('../roverManager');
const { issueCommand } = require('../commandService');
const STATES = { const STATES = {
NORMAL: 'normal', NORMAL: 'normal',
@@ -29,6 +30,7 @@ function getState(roverId) {
dockedCharging: false, dockedCharging: false,
charging: false, charging: false,
batteryLocked: false, batteryLocked: false,
completionAnnounced: false,
waitingSince: null, waitingSince: null,
}); });
} }
@@ -61,6 +63,11 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
} }
if (!state.urgent && batteryState?.urgentActive) { if (!state.urgent && batteryState?.urgentActive) {
state.urgent = true; state.urgent = true;
try {
issueCommand(roverId, { type: 'song', song: { notes: [{ note: 72, duration: 8 }, { note: 0, duration: 8 }, { note: 72, duration: 16 }] } });
} catch (err) {
logger.warn('Failed to play urgent battery warning', { roverId, error: err.message });
}
publishEvent({ publishEvent({
source: 'batteryManager', source: 'batteryManager',
type: 'battery.urgent', type: 'battery.urgent',
@@ -137,6 +144,18 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) || (batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
(batteryState?.percent != null && batteryState.percent >= 0.99); (batteryState?.percent != null && batteryState.percent >= 0.99);
// Announce once per dock visit, independently of the charging lock. Keep the
// latch at 100% so small charge fluctuations cannot repeat the sound.
if (!onDock) state.completionAnnounced = false;
if (onDock && batteryState?.percentDisplay >= 100 && !state.completionAnnounced) {
state.completionAnnounced = true;
publishEvent({
source: 'batteryManager',
type: 'battery.chargeComplete',
payload: { roverId, batteryState },
});
}
const needsCharge = state.warned; const needsCharge = state.warned;
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) { if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
@@ -110,7 +110,7 @@ function maybeSpeak(socket, message, ttsOptions) {
issueCommand(message.roverId, { issueCommand(message.roverId, {
type: 'tts', type: 'tts',
tts: { tts: {
text: message.text, text: message.text.replace(/\b(?:https?:\/\/|www\.)\S{25,}[^\s.,!?;:"'()\[\]{}]/g, ' [link] '),
engine: ttsOptions.engine, engine: ttsOptions.engine,
voice: ttsOptions.voice, voice: ttsOptions.voice,
pitch: ttsOptions.pitch, pitch: ttsOptions.pitch,
@@ -91,9 +91,9 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
const requester = buildRequesterLabel(socket); const requester = buildRequesterLabel(socket);
const jobId = buildReplayJobId('web-chat'); const jobId = buildReplayJobId('web-chat');
const attempt = replayApi.tryTriggerReplay({ by: { source: 'web-chat', requester } }); const attempt = replayApi.tryTriggerReplay({ jobId, by: { source: 'web-chat', requester } });
if (!attempt.ok) { if (!attempt.ok) {
await message.reply({ content: `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.` }); await message.reply({ content: `Replay denied: ${attempt.error}` });
return; return;
} }
@@ -2,6 +2,7 @@
// Purpose: Centralizes rover battery/status snapshot formatting and embed rendering for Discord surfaces. // Purpose: Centralizes rover battery/status snapshot formatting and embed rendering for Discord surfaces.
// Scope: Shared presentation logic for status commands and admin alert battery event posts. // Scope: Shared presentation logic for status commands and admin alert battery event posts.
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
const { getMode } = require('../modeManager');
function formatVoltage(voltageMv) { function formatVoltage(voltageMv) {
if (voltageMv == null) return 'n/a'; if (voltageMv == null) return 'n/a';
@@ -69,6 +70,7 @@ function buildRoverStatusSnapshot(record) {
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) { function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(color).setTimestamp(new Date()); const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(color).setTimestamp(new Date());
embed.addFields({ name: 'Server mode', value: getMode(), inline: true });
const snapshots = records.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean); const snapshots = records.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean);
if (!snapshots.length) { if (!snapshots.length) {
embed.setDescription('No rovers online.'); embed.setDescription('No rovers online.');
@@ -1,6 +1,6 @@
// Discord Replay Command // Discord Replay Command
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow. // Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs. // Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs.
const { AttachmentBuilder } = require('discord.js'); const { AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io'); const io = require('../../../globals/io');
const { hostReplay } = require('../../replayMediaService'); const { hostReplay } = require('../../replayMediaService');
@@ -60,12 +60,6 @@ function createReplayCommand({
return; return;
} }
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
if (!attempt.ok) {
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
return;
}
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
const job = createReplayJob({ const job = createReplayJob({
id: buildReplayJobId('discord'), id: buildReplayJobId('discord'),
@@ -74,17 +68,23 @@ function createReplayCommand({
sources: resolved.sources || [], sources: resolved.sources || [],
includeSidebar: true, includeSidebar: true,
}); });
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) }); const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } });
if (!attempt.ok) {
const progressMessage = await message.reply({ await replyDenied(message, `Replay denied: ${attempt.error}`);
content: sanitizeMentions(buildAcceptedMessage(job)), return;
allowedMentions: DEFAULT_ALLOWED_MENTIONS, }
});
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
let progressMessage = null;
let stopTyping = () => {};
let builtReplay = null; let builtReplay = null;
let deliveredMedia = null; let deliveredMedia = null;
try { try {
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
progressMessage = await message.reply({
content: sanitizeMentions(buildAcceptedMessage(job)),
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') }); jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
@@ -16,7 +16,7 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
a direct browser load. Keeping the setup and admin routes in this explicit a direct browser load. Keeping the setup and admin routes in this explicit
allowlist prevents them from working only after client-side navigation. allowlist prevents them from working only after client-side navigation.
*/ */
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin'], async (req, res) => { app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin', '/mic'], async (req, res) => {
try { try {
const html = await renderIndexHtml(req); const html = await renderIndexHtml(req);
res.type('html').send(html); res.type('html').send(html);
@@ -20,6 +20,7 @@ function normalizeAdditionalHosts(rawHosts) {
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) { function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
const media = config?.media || {}; const media = config?.media || {};
const webrtcPort = media.webrtcPort ?? 8189;
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts); const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
let publicHostname = ''; let publicHostname = '';
try { try {
@@ -55,10 +56,10 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
webrtc: true, webrtc: true,
// WHEP and WHIP signaling is public only through the Node /video proxy. // WHEP and WHIP signaling is public only through the Node /video proxy.
// ICE transport on 8189 remains directly reachable by browsers. // The configured ICE transport port remains directly reachable by browsers.
webrtcAddress: '127.0.0.1:8889', webrtcAddress: '127.0.0.1:8889',
webrtcLocalUDPAddress: ':8189', webrtcLocalUDPAddress: `:${webrtcPort}`,
webrtcLocalTCPAddress: ':8189', webrtcLocalTCPAddress: `:${webrtcPort}`,
webrtcAdditionalHosts: additionalHosts, webrtcAdditionalHosts: additionalHosts,
webrtcICEServers2: [ webrtcICEServers2: [
{ url: 'stun:stun.l.google.com:19302' }, { url: 'stun:stun.l.google.com:19302' },
@@ -81,6 +82,7 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
{ action: 'pprof' }, { action: 'pprof' },
], ],
paths: { paths: {
'~^room-audio/': { source: 'publisher' },
all: { all: {
source: 'publisher', source: 'publisher',
sourceOnDemand: false, sourceOnDemand: false,
@@ -1,18 +1,24 @@
// Media Transport Configuration // Media Transport Configuration
// Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL. // Purpose: Defines the WebRTC media port and additional ICE hosts.
// Scope: Contains configuration metadata only and never starts MediaMTX. // Scope: Contains configuration metadata only and never starts MediaMTX.
const { strictObject, stringArray } = require('../../configuration/schemaHelpers'); const { strictObject, stringArray, integer } = require('../../configuration/schemaHelpers');
module.exports = { module.exports = {
key: 'media', key: 'media',
defaultValue: { defaultValue: {
webrtcPort: 8189,
additionalHosts: [], additionalHosts: [],
}, },
schema: strictObject({ schema: strictObject({
webrtcPort: integer({
title: 'WebRTC port',
description: 'UDP and TCP port for live media. Forward the same external port to this server; HTTPS signaling continues through the web UI.',
minimum: 1, maximum: 65535,
}),
additionalHosts: stringArray({ additionalHosts: stringArray({
title: 'Additional ICE hosts', title: 'Additional ICE hosts',
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 }, item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true }, array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
}), }),
}, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }), }, { title: 'Media', description: 'Configures the WebRTC media port and additional network candidates.', required: ['webrtcPort', 'additionalHosts'] }),
}; };
@@ -400,6 +400,7 @@ function createOvercurrentProtectionService(options = {}) {
type: 'drive', type: 'drive',
driveDirect: { left: 0, right: 0 }, driveDirect: { left: 0, right: 0 },
}); });
issueAdjustedCommand(roverId, { type: 'song', song: { notes: [{ note: 76, duration: 8 }, { note: 72, duration: 8 }, { note: 67, duration: 16 }] } });
logger.warn('Stopped rover after persistent wheel overcurrent', { logger.warn('Stopped rover after persistent wheel overcurrent', {
roverId, roverId,
motor: stalledWheel, motor: stalledWheel,
@@ -1,5 +1,11 @@
const { evaluateTools } = require('./tools'); const { evaluateTools } = require('./tools');
function formatTimeContext(timestamp, now) {
const at = Number(timestamp);
if (!Number.isFinite(at) || at <= 0) return 'time=unknown';
return `at=${new Date(at).toISOString()} age_seconds=${Math.max(0, Math.floor((now - at) / 1000))}`;
}
function normalizeNeatoIssue(value) { function normalizeNeatoIssue(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return 'none'; if (!raw) return 'none';
@@ -7,12 +13,21 @@ function normalizeNeatoIssue(value) {
return raw; return raw;
} }
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) { function toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone, now = Date.now(), previousRunAt }) {
const lines = []; const lines = [];
lines.push(`current_time: ${new Date(now).toISOString()}`);
lines.push(`local_time: ${new Date(now).toLocaleString('en-US', { timeZone: timezone, hour12: false })} timezone=${timezone}`);
lines.push(`previous_run: ${formatTimeContext(previousRunAt, now)}`);
lines.push(`trigger: ${triggerReason || 'heartbeat'}`); lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
lines.push(`mode: ${mode || 'unknown'}`); lines.push(`mode: ${mode || 'unknown'}`);
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`); lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`); lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
lines.push(`activity_controls: enabled=${activityState?.enabled ? 'yes' : 'no'} connected=${activityState?.connected ? 'yes' : 'no'}`);
for (const item of activityState?.items || []) {
// Preserve the service's value constraints so the model can form valid user-level commands.
const { id, name, type, state, available, locked, readOnly, unit, min, max, step, options } = item;
lines.push(`activity_item: ${JSON.stringify({ id, name, type, state, available, locked, readOnly, unit, min, max, step, options })}`);
}
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`); lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError); const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert); const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
@@ -56,8 +71,8 @@ function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster
return lines.join('\n'); return lines.join('\n');
} }
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) { function buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState }) {
return evaluateTools({ mode, homeAssistantState, neatoState, liftState }); return evaluateTools({ mode, homeAssistantState, activityState, neatoState, liftState });
} }
function formatToolResultMessages(toolCalls) { function formatToolResultMessages(toolCalls) {
@@ -88,7 +103,7 @@ function formatToolResultMessages(toolCalls) {
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }]; return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
} }
function buildConversation({ recentMessages, name }) { function buildConversation({ recentMessages, name, now = Date.now() }) {
const messages = []; const messages = [];
(recentMessages || []).forEach((entry) => { (recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim(); const text = String(entry?.text || '').trim();
@@ -97,12 +112,13 @@ function buildConversation({ recentMessages, name }) {
const isAssistant = Boolean(entry?.bot); const isAssistant = Boolean(entry?.bot);
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim(); const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
const timing = `[${formatTimeContext(entry?.ts, now)}]`;
if (isAssistant) { if (isAssistant) {
if (text) messages.push({ role: 'assistant', content: text }); if (text) messages.push({ role: 'assistant', content: `${timing} ${text}` });
messages.push(...toolResultMessages); messages.push(...toolResultMessages.map((message) => ({ ...message, content: `${message.content}\nmessage_time: ${timing}` })));
return; return;
} }
if (text) messages.push({ role: 'user', content: `${nickname}: ${text}` }); if (text) messages.push({ role: 'user', content: `${timing} ${nickname}: ${text}` });
}); });
return messages; return messages;
} }
@@ -143,6 +159,7 @@ function buildModelMessages({
} }
module.exports = { module.exports = {
formatTimeContext,
toStateUpdate, toStateUpdate,
buildToolState, buildToolState,
buildConversation, buildConversation,
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService'); const { verificationEvents } = require('../verificationService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const activityControls = require('../homeAssistantActivitiesService');
const neatoService = require('../neatoService'); const neatoService = require('../neatoService');
const liftService = require('../liftService'); const liftService = require('../liftService');
const buttonBoxService = require('../buttonBoxService'); const buttonBoxService = require('../buttonBoxService');
@@ -27,7 +28,7 @@ const {
normalizeMs, normalizeMs,
} = require('./constants'); } = require('./constants');
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers'); const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder'); const { toStateUpdate, buildToolState, buildConversation, buildModelMessages, formatTimeContext } = require('./contextBuilder');
const { buildOllamaTools, executeToolAction } = require('./tools'); const { buildOllamaTools, executeToolAction } = require('./tools');
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore'); const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
@@ -91,6 +92,7 @@ const runtime = {
runHistory: [], runHistory: [],
liveToolCalls: [], liveToolCalls: [],
pendingDirectAddressRun: false, pendingDirectAddressRun: false,
lastDecisionAt: null,
memoryStore: loadMemory(), memoryStore: loadMemory(),
contextResetAt: Date.now(), contextResetAt: Date.now(),
}; };
@@ -365,7 +367,7 @@ function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
}); });
} }
function buildRecentEventsSummary() { function buildRecentEventsSummary(now) {
const events = (runtime.liveToolCalls || []) const events = (runtime.liveToolCalls || [])
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked')) .filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
.slice(-3) .slice(-3)
@@ -373,40 +375,50 @@ function buildRecentEventsSummary() {
const tool = String(entry.tool || 'unknown'); const tool = String(entry.tool || 'unknown');
const phase = String(entry.phase || 'unknown'); const phase = String(entry.phase || 'unknown');
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : ''; const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
return `- tool=${tool} phase=${phase}${err}`; return `- ${formatTimeContext(entry.at, now)} tool=${tool} phase=${phase}${err}`;
}); });
if (!events.length) return '- none'; if (!events.length) return '- none';
return events.join('\n'); return events.join('\n');
} }
async function runDecision(triggerReason) { async function runDecision(triggerReason) {
// Use one reference time so all ages in this model request agree.
const now = Date.now();
const previousRunAt = runtime.lastDecisionAt;
runtime.lastDecisionAt = now;
const runId = runtime.tickCount; const runId = runtime.tickCount;
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null }); updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
const mode = getMode(); const mode = getMode();
const homeAssistantState = getHomeAssistantState(); const homeAssistantState = getHomeAssistantState();
const activityState = activityControls.getState();
const neatoState = getNeatoState(); const neatoState = getNeatoState();
const liftState = getLiftState(); const liftState = getLiftState();
const roster = buildRosterSummary(); const roster = buildRosterSummary();
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }); const stateUpdate = toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone: loadConfig().timezone, now, previousRunAt });
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState }); const toolState = buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState });
let chatCount = 0;
let botCount = 0;
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true }) const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt) .filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => { .filter((entry) => {
if (!entry?.roverId) return true; if (!entry?.roverId) return true;
return isPublicChatTargetId(entry.roverId); return isPublicChatTargetId(entry.roverId);
}) })
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT)); // Select newest entries within each budget, then restore chronological order.
const conversationMessages = buildConversation({ recentMessages: recentConversation, name }); .reverse()
.filter((entry) => entry?.bot ? ++botCount <= MAX_BOT_CONTEXT : ++chatCount <= MAX_CHAT_CONTEXT)
.reverse();
const conversationMessages = buildConversation({ recentMessages: recentConversation, name, now });
const systemPrompt = await readPrompt(); const systemPrompt = await readPrompt();
const modelMessages = buildModelMessages({ const modelMessages = buildModelMessages({
systemPrompt, systemPrompt,
stateUpdate, stateUpdate,
memorySummary: summarizeMemory(runtime.memoryStore), memorySummary: summarizeMemory(runtime.memoryStore),
recentEvents: buildRecentEventsSummary(), recentEvents: buildRecentEventsSummary(now),
conversationMessages, conversationMessages,
availableTools: toolState.available, availableTools: toolState.available,
blockedTools: toolState.blocked, blockedTools: toolState.blocked,
@@ -680,6 +692,7 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
runtime.contextResetAt = Date.now(); runtime.contextResetAt = Date.now();
runtime.runHistory = []; runtime.runHistory = [];
runtime.liveToolCalls = []; runtime.liveToolCalls = [];
runtime.lastDecisionAt = null;
runtime.generationCount = 0; runtime.generationCount = 0;
runtime.generationTotalMs = 0; runtime.generationTotalMs = 0;
if (resetPersistentMemory) { if (resetPersistentMemory) {
@@ -0,0 +1,32 @@
const activityControls = require('../../homeAssistantActivitiesService');
const { getMode } = require('../../modeManager');
module.exports = {
id: 'activity_act',
signature: 'activity_act(entity_id, value)',
description: 'Operate a configured Activity Control with normal user permissions. Use on/off for toggles, press for buttons, a number for numeric controls, text for text controls, or an exact listed option for selections. Locked and read-only items cannot be changed.',
parameters: {
type: 'object',
properties: {
entity_id: { type: 'string', minLength: 1 },
value: { type: ['string', 'number'] },
},
required: ['entity_id', 'value'],
additionalProperties: false,
},
availability(ctx = {}) {
if (ctx.mode === 'admin' || ctx.mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${ctx.mode}` };
}
if (!ctx.activityState?.enabled || !ctx.activityState?.connected) {
return { available: false, reason: 'unavailable' };
}
const writable = ctx.activityState.items?.some((item) => item.available && !item.locked && !item.readOnly && item.type !== 'readOnly');
return { available: Boolean(writable), reason: writable ? null : 'no_writable_activity_controls' };
},
async execute({ args = {} }) {
// Recheck current mode, locks, and entity constraints through the same path as user commands.
await activityControls.act(args.entity_id, args.value, { role: 'user', mode: getMode() });
return { ok: true };
},
};
@@ -12,6 +12,7 @@ const neatoClearErrors = require('./neatoClearErrors');
const haSetEntity = require('./haSetEntity'); const haSetEntity = require('./haSetEntity');
const haSetLightColor = require('./haSetLightColor'); const haSetLightColor = require('./haSetLightColor');
const buttonBoxAddCount = require('./buttonBoxAddCount'); const buttonBoxAddCount = require('./buttonBoxAddCount');
const activityAct = require('./activityAct');
const TOOL_DEFINITIONS = [ const TOOL_DEFINITIONS = [
memoryRead, memoryRead,
@@ -28,6 +29,7 @@ const TOOL_DEFINITIONS = [
haSetEntity, haSetEntity,
haSetLightColor, haSetLightColor,
buttonBoxAddCount, buttonBoxAddCount,
activityAct,
]; ];
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool])); const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
@@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) {
requestedBy: payload.requestedBy, requestedBy: payload.requestedBy,
}); });
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerContext = null; let providerContext = null;
try { try {
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerError = null; let providerError = null;
if (preferredDeliveryProvider?.begin) { if (preferredDeliveryProvider?.begin) {
try { try {
@@ -2,6 +2,7 @@
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text. // Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline. // Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
const { updateReplayStatus } = require('../replayEngineV2/cooldown');
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false }; const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
const FUZZY_THRESHOLD = 0.42; const FUZZY_THRESHOLD = 0.42;
@@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
function emit(job, status, extra = {}) { function emit(job, status, extra = {}) {
const payload = buildPayload(job, status, extra); const payload = buildPayload(job, status, extra);
updateReplayStatus(payload);
if (status === 'accepted') {
// Sound once per participating rover; a disconnected rover must not abort video creation.
const { issueCommand } = require('../commandService');
const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id));
for (const roverId of roverIds) {
try {
issueCommand(roverId, {
type: 'song',
song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] },
});
} catch (err) {
logger?.warn?.('Unable to play replay tune', { roverId, error: err.message });
}
}
}
io.emit('replay:status', payload); io.emit('replay:status', payload);
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media); if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
if (status === 'failed') io.emit('replay:failed', payload); if (status === 'failed') io.emit('replay:failed', payload);
+43 -22
View File
@@ -4,40 +4,61 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const COOLDOWN_MS = 10 * 1000; const COOLDOWN_MS = 10 * 1000;
const MAX_CONCURRENT_JOBS = 3;
const replayEvents = new EventEmitter(); const replayEvents = new EventEmitter();
const activeJobs = new Map();
let status = null;
let lastTriggeredAt = null; let lastTriggeredAt = null;
let lastTriggeredBy = null; let lastTriggeredBy = null;
function getRemainingMs(now = Date.now()) {
if (!lastTriggeredAt) return 0;
return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt));
}
function getReplayState() { function getReplayState() {
const remainingMs = getRemainingMs(); const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt));
return { return {
cooldownMs: COOLDOWN_MS, cooldownMs: COOLDOWN_MS,
lastTriggeredAt, lastTriggeredAt,
lastTriggeredBy, lastTriggeredBy,
remainingMs, remainingMs,
available: remainingMs === 0, atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS,
activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) =>
sources.map((source) => `${source.type}:${source.id}`),
))],
status,
available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS,
}; };
} }
function tryTriggerReplay(by = null) { function emitUpdate() {
const remainingMs = getRemainingMs(); replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy });
if (remainingMs > 0) {
return { ok: false, remainingMs, state: getReplayState() };
}
lastTriggeredAt = Date.now();
lastTriggeredBy = by || null;
const state = getReplayState();
replayEvents.emit('update', { state, by: lastTriggeredBy });
return { ok: true, state };
} }
module.exports = { function tryTriggerReplay({ jobId, by = null } = {}) {
tryTriggerReplay, const state = getReplayState();
getReplayState, if (state.atJobLimit) {
replayEvents, return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state };
}; }
if (state.remainingMs > 0) {
return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state };
}
if (!jobId) throw new Error('Replay job id required');
activeJobs.set(jobId, []);
status = { jobId, status: 'accepted' };
lastTriggeredAt = Date.now();
lastTriggeredBy = by;
emitUpdate();
return { ok: true, state: getReplayState() };
}
function updateReplayStatus(payload) {
if (!activeJobs.has(payload.jobId)) return;
if (payload.jobId === status?.jobId) {
status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message };
}
if (payload.status === 'ready' || payload.status === 'failed') {
activeJobs.delete(payload.jobId);
} else {
activeJobs.set(payload.jobId, payload.sources || []);
}
emitUpdate();
}
module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus };
+7 -1
View File
@@ -6,6 +6,7 @@ const { promisify } = require('util');
const fsp = require('fs/promises'); const fsp = require('fs/promises');
const logger = require('../../globals/logger').child('replayEngineV2'); const logger = require('../../globals/logger').child('replayEngineV2');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { roomAudioEvents } = require('../roomAudioService');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const { const {
@@ -40,8 +41,8 @@ function getActiveSegmentRoot() {
return runtime.activeSegmentRoot; return runtime.activeSegmentRoot;
} }
const workerManager = createWorkerManager({ getActiveSegmentRoot });
const segmentStore = createSegmentStore({ getActiveSegmentRoot }); const segmentStore = createSegmentStore({ getActiveSegmentRoot });
const workerManager = createWorkerManager({ getActiveSegmentRoot, onWorkerClosed: segmentStore.refreshIndexForWorker });
const sidebarRenderer = createSidebarRenderer({ execFileAsync, ensureDir }); const sidebarRenderer = createSidebarRenderer({ execFileAsync, ensureDir });
const replayBuilder = createReplayBuilder({ const replayBuilder = createReplayBuilder({
execFileAsync, execFileAsync,
@@ -50,6 +51,7 @@ const replayBuilder = createReplayBuilder({
renderSidebarVideo: sidebarRenderer.renderSidebarVideo, renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource, getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource, getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
getRoomAudioEntries: segmentStore.getRoomAudioEntries,
overlapping: segmentStore.overlapping, overlapping: segmentStore.overlapping,
}); });
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources }); registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
@@ -88,6 +90,10 @@ async function start() {
}, CLEANUP_INTERVAL_MS); }, CLEANUP_INTERVAL_MS);
} }
roomAudioEvents.on('update', () => {
tick().catch((err) => logger.warn('room audio update tick failed', err.message));
});
roomCameraEvents.on('update', () => { roomCameraEvents.on('update', () => {
tick().catch((err) => logger.warn('room camera update tick failed', err.message)); tick().catch((err) => logger.warn('room camera update tick failed', err.message));
}); });
@@ -51,7 +51,7 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts); return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
} }
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) { function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, getRoomAudioEntries, overlapping }) {
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) { function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
const tentativeEnd = nowMs - guardMs; const tentativeEnd = nowMs - guardMs;
const sourceEnds = []; const sourceEnds = [];
@@ -186,6 +186,28 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources'); if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources');
const roomAudioEntries = await pinSegmentFiles(
overlapping(getRoomAudioEntries(), tStart, tEnd), tmpDir, 'room-audio-seg',
);
if (roomAudioEntries.length) {
// Place each segment at its recorded time. A mic joining late or
// reconnecting must leave silence in the gap, not shift its speech earlier.
const audioInputs = roomAudioEntries.flatMap((entry) => ['-i', entry.filePath]);
const audioFilters = roomAudioEntries.map((entry, index) => {
const start = Math.max(0, (tStart - entry.startMs) / 1000);
const duration = (Math.min(tEnd, entry.endMs) - Math.max(tStart, entry.startMs)) / 1000;
const delay = Math.max(0, Math.round(entry.startMs - tStart));
return `[${index}:a]atrim=start=${start.toFixed(3)}:duration=${duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delay}:all=1[room${index}]`;
});
const refs = roomAudioEntries.map((_, index) => `[room${index}]`).join('');
audioFilters.push(`${refs}amix=inputs=${roomAudioEntries.length}:normalize=0[roomout]`);
const roomAudioPath = path.join(tmpDir, 'room-audio.m4a');
await execFileAsync(FFMPEG_BIN, ['-y', '-hide_banner', '-loglevel', 'error', ...audioInputs,
'-filter_complex', audioFilters.join(';'), '-map', '[roomout]', '-vn', '-ac', '1', '-ar', '48000',
'-c:a', 'aac', '-b:a', '96k', roomAudioPath]);
normalizedAudios.push(roomAudioPath);
}
const layout = buildGridLayout(normalizedVideos.length); const layout = buildGridLayout(normalizedVideos.length);
const { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path)); const { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path));
let tileWidth = maxWidth || 640; let tileWidth = maxWidth || 640;
@@ -6,9 +6,10 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const logger = require('../../globals/logger').child('replayEngineV2'); const logger = require('../../globals/logger').child('replayEngineV2');
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants'); const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
const { workers, segmentIndex } = require('./state'); const { workers, pendingWorkerStarts, segmentIndex } = require('./state');
const { sourceKey, sourceDirForKey } = require('./sources'); const { sourceKey, sourceDirForKey } = require('./sources');
const ptzCameraService = require('../ptzCameraService'); const ptzCameraService = require('../ptzCameraService');
const SEGMENT_FILENAME_PATTERN = /^seg-[a-f0-9-]+-\d{6}\.mp4$/;
async function ensureDir(dir) { async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true }); await fsp.mkdir(dir, { recursive: true });
@@ -25,7 +26,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
return; return;
} }
const segmentFiles = files.filter((entry) => entry.isFile() && /^seg-\d{6}\.mp4$/.test(entry.name)).map((entry) => entry.name).sort(); const segmentFiles = files.filter((entry) => entry.isFile() && SEGMENT_FILENAME_PATTERN.test(entry.name)).map((entry) => entry.name).sort();
if (!segmentFiles.length) { if (!segmentFiles.length) {
segmentIndex.set(key, []); segmentIndex.set(key, []);
return; return;
@@ -64,6 +65,13 @@ function createSegmentStore({ getActiveSegmentRoot }) {
async function cleanupOldFiles() { async function cleanupOldFiles() {
const cutoff = Date.now() - BUFFER_SECONDS * 1000; const cutoff = Date.now() - BUFFER_SECONDS * 1000;
// Retention applies to stopped workers too; dynamic microphone sessions
// must not leave permanent index entries after their audio expires.
for (const [key, entries] of segmentIndex) {
const retained = entries.filter((entry) => entry.endMs >= cutoff);
if (!retained.length && !workers.has(key) && !pendingWorkerStarts.has(key)) segmentIndex.delete(key);
else segmentIndex.set(key, retained);
}
const root = getActiveSegmentRoot(); const root = getActiveSegmentRoot();
try { try {
await ensureDir(root); await ensureDir(root);
@@ -81,6 +89,10 @@ function createSegmentStore({ getActiveSegmentRoot }) {
if (stat.mtimeMs < cutoff) await fsp.unlink(filePath); if (stat.mtimeMs < cutoff) await fsp.unlink(filePath);
} catch {} } catch {}
} }
if (!workers.has(dirent.name) && !pendingWorkerStarts.has(dirent.name)) {
// rmdir only removes empty directories, preserving buffered or newly written files.
try { await fsp.rmdir(dirPath); } catch {}
}
} }
} catch (err) { } catch (err) {
logger.warn('cleanup failed', err.message); logger.warn('cleanup failed', err.message);
@@ -115,6 +127,14 @@ function createSegmentStore({ getActiveSegmentRoot }) {
return []; return [];
} }
function getRoomAudioEntries() {
// Include retained buffers from microphones that disconnected during the
// replay window, rather than limiting history to currently live streams.
return [...segmentIndex.entries()]
.filter(([key]) => key.startsWith('roomAudio__audio__'))
.flatMap(([, entries]) => entries);
}
function overlapping(entries, startMs, endMs) { function overlapping(entries, startMs, endMs) {
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs); return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
} }
@@ -153,7 +173,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
try { try {
const files = fs.readdirSync(dir); const files = fs.readdirSync(dir);
for (const name of files) { for (const name of files) {
if (!/^seg-\d{6}\.mp4$/.test(name)) continue; if (!SEGMENT_FILENAME_PATTERN.test(name)) continue;
const full = path.join(dir, name); const full = path.join(dir, name);
let stat; let stat;
try { stat = fs.statSync(full); } catch { continue; } try { stat = fs.statSync(full); } catch { continue; }
@@ -172,10 +192,12 @@ function createSegmentStore({ getActiveSegmentRoot }) {
return { return {
refreshSegmentIndex, refreshSegmentIndex,
refreshIndexForWorker,
cleanupOldFiles, cleanupOldFiles,
getVideoEntriesForSource, getVideoEntriesForSource,
getAudioEntriesForRover, getAudioEntriesForRover,
getAudioEntriesForSource, getAudioEntriesForSource,
getRoomAudioEntries,
overlapping, overlapping,
bootstrapIndexFromDisk, bootstrapIndexFromDisk,
getReplayHealthSnapshot, getReplayHealthSnapshot,
@@ -1,6 +1,6 @@
// Replay Socket Hooks // Replay Socket Hooks
// Purpose: Registers web socket replay-trigger handler that publishes replay requests. // Purpose: Registers web socket replay-trigger handler that publishes replay requests.
// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests. // Scope: Applies replay mode/availability/source validation for socket-triggered replay requests.
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('replaySocket'); const logger = require('../../globals/logger').child('replaySocket');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
@@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
const title = normalizeReplayTitle(payload?.title); const title = normalizeReplayTitle(payload?.title);
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar); const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
const jobId = buildReplayJobId('web'); const jobId = buildReplayJobId('web');
const attempt = tryTriggerReplay({ by: { source: 'web', requester } }); const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } });
if (!attempt.ok) { if (!attempt.ok) {
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state }); cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state });
return; return;
} }
@@ -2,8 +2,10 @@
// Purpose: Resolves replay-capable media sources and ffmpeg worker arguments. // Purpose: Resolves replay-capable media sources and ffmpeg worker arguments.
// Scope: Converts live rover/room state into stable source descriptors and stream worker config. // Scope: Converts live rover/room state into stable source descriptors and stream worker config.
const path = require('path'); const path = require('path');
const { randomUUID } = require('crypto');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService'); const { getRoomCameras } = require('../roomCameraService');
const { getActiveRoomAudioStreams } = require('../roomAudioService');
const ptzCameraService = require('../ptzCameraService'); const ptzCameraService = require('../ptzCameraService');
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants'); const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
@@ -51,12 +53,17 @@ function listDesiredSources() {
// both come from the same live MediaMTX path, unlike rovers where audio is a // both come from the same live MediaMTX path, unlike rovers where audio is a
// separate published stream. // separate published stream.
sources.push(...ptzCameraService.getReplayWorkerSources()); sources.push(...ptzCameraService.getReplayWorkerSources());
for (const stream of getActiveRoomAudioStreams()) {
sources.push({ id: stream.id, sourceType: 'roomAudio', kind: 'audio', label: stream.name,
inputUrl: `rtsp://127.0.0.1:8554/room-audio/${encodeURIComponent(stream.id)}` });
}
return sources; return sources;
} }
function buildWorkerArgs(activeSegmentRoot, source) { function buildWorkerArgs(activeSegmentRoot, source) {
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source)); const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
const pattern = path.join(dir, 'seg-%06d.mp4'); // Each capture process owns its filenames so restarts preserve buffered segments.
const pattern = path.join(dir, `seg-${randomUUID()}-%06d.mp4`);
// MediaMTX and RTSP cameras use TCP so replay capture has one reliable // MediaMTX and RTSP cameras use TCP so replay capture has one reliable
// transport and never falls back to separate RTP/RTCP UDP listeners. // transport and never falls back to separate RTP/RTCP UDP listeners.
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl) const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
@@ -12,7 +12,7 @@ async function ensureDir(dir) {
await fsp.mkdir(dir, { recursive: true }); await fsp.mkdir(dir, { recursive: true });
} }
function createWorkerManager({ getActiveSegmentRoot }) { function createWorkerManager({ getActiveSegmentRoot, onWorkerClosed }) {
function startWorker(source) { function startWorker(source) {
const key = sourceKey(source); const key = sourceKey(source);
if (workers.has(key) || pendingWorkerStarts.has(key)) return; if (workers.has(key) || pendingWorkerStarts.has(key)) return;
@@ -21,26 +21,36 @@ function createWorkerManager({ getActiveSegmentRoot }) {
const dir = sourceDirForKey(getActiveSegmentRoot(), key); const dir = sourceDirForKey(getActiveSegmentRoot(), key);
ensureDir(dir) ensureDir(dir)
.then(() => { .then(() => {
if (workers.has(key)) { // Directory creation can finish after a microphone has disconnected.
if (workers.has(key) || !listDesiredSources().some((entry) => sourceKey(entry) === key)) {
pendingWorkerStarts.delete(key); pendingWorkerStarts.delete(key);
return; return;
} }
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] }); const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
workers.set(key, { key, source, proc }); const worker = { key, source, proc, stopping: false };
workers.set(key, worker);
pendingWorkerStarts.delete(key); pendingWorkerStarts.delete(key);
proc.stderr.on('data', (chunk) => { proc.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim(); const text = String(chunk || '').trim();
if (!text) return; if (!text) return;
logger.warn('worker stderr', { key, text: text.slice(0, 500) }); logger.warn('worker stderr', { key, text: text.slice(0, 500) });
}); });
proc.on('exit', (code, signal) => { proc.on('error', (err) => logger.warn('worker process failed', { key, error: err.message }));
const current = workers.get(key); worker.finished = new Promise((resolve) => {
if (current?.proc === proc) workers.delete(key); proc.once('close', async (code, signal) => {
logger.warn('worker exited', { key, code, signal }); clearTimeout(worker.killTimer);
setTimeout(() => { // FFmpeg writes the final MP4 trailer before close. Index it before
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key); // releasing this worker so a disconnect cannot drop its last audio.
if (desired && !workers.has(key)) startWorker(desired); try { await onWorkerClosed(worker); }
}, 1500); catch (err) { logger.warn('final worker indexing failed', { key, error: err.message }); }
if (workers.get(key) === worker) workers.delete(key);
resolve();
logger.warn('worker exited', { key, code, signal });
if (!worker.stopping) setTimeout(() => {
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key);
if (desired && !workers.has(key)) startWorker(desired);
}, 1500);
});
}); });
}) })
.catch((err) => { .catch((err) => {
@@ -52,8 +62,14 @@ function createWorkerManager({ getActiveSegmentRoot }) {
function stopWorker(key) { function stopWorker(key) {
const worker = workers.get(key); const worker = workers.get(key);
if (!worker) return; if (!worker) return;
try { worker.proc.kill('SIGTERM'); } catch {} if (!worker.stopping) {
workers.delete(key); worker.stopping = true;
worker.proc.kill('SIGTERM');
// Bound shutdown if a capture is blocked waiting on its network input.
worker.killTimer = setTimeout(() => worker.proc.kill('SIGKILL'), 3000);
worker.killTimer.unref();
}
return worker.finished;
} }
async function syncWorkers() { async function syncWorkers() {
@@ -63,9 +79,11 @@ function createWorkerManager({ getActiveSegmentRoot }) {
const key = sourceKey(source); const key = sourceKey(source);
if (!workers.has(key)) startWorker(source); if (!workers.has(key)) startWorker(source);
} }
const stops = [];
for (const key of Array.from(workers.keys())) { for (const key of Array.from(workers.keys())) {
if (!desiredKeys.has(key)) stopWorker(key); if (!desiredKeys.has(key)) stops.push(stopWorker(key));
} }
await Promise.all(stops);
} }
return { startWorker, stopWorker, syncWorkers }; return { startWorker, stopWorker, syncWorkers };
@@ -0,0 +1,14 @@
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'roomAudio', feature: true,
defaultValue: { enabled: false, sources: [] },
schema: strictObject({
enabled: boolean({ description: 'Enable room audio and local browser microphone publishing.' }),
sources: { type: 'array', items: strictObject({
id: string({ minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
name: string({ minLength: 1, maxLength: 120 }),
type: { type: 'string', enum: ['device', 'stream'] },
input: string({ minLength: 1, maxLength: 2048, description: 'ALSA device (for example default or plughw:CARD=USB,DEV=0), or a network stream URL.' }),
}, { required: ['id', 'name', 'type', 'input'] }) },
}, { title: 'Room audio', required: ['enabled', 'sources'] }),
};
@@ -0,0 +1,171 @@
const { spawn } = require('child_process');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('roomAudio');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const { getMode, MODES } = require('../modeManager');
const { getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const { canBecomeSpectator } = require('../authService');
const { isDeterred } = require('../verificationService');
const videoSessions = require('../videoSessions');
const roomAudioEvents = new EventEmitter();
const sources = new Map();
const mediaSessions = new Map();
let enabled = false;
let active = [];
let polling = false;
let generation = 0;
function canListen(socket) {
if (!enabled || !socket) return false;
const mode = getMode();
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
if (isAdmin(socket)) return true;
if (getRole(socket) === 'spectator') return canBecomeSpectator(socket);
// Room audio follows general driving eligibility, without tying listening
// to rover assignment, private-rover access, or the current driving turn.
return getRole(socket) === 'user' && mode !== MODES.ADMIN && !isDeterred(socket);
}
function canPublish(socket, id) {
return enabled && isLocalNetwork(getSocketIp(socket)) && sources.get(id)?.socketId === socket.id;
}
function getRoomAudioStreams(socket) { return canListen(socket) ? active : []; }
function getActiveRoomAudioStreams() { return active; }
function trackMediaSession(id, token) { if (id) mediaSessions.set(id, token); }
function hasStream(id) { return active.some((source) => source.id === id); }
function stopSource(id) {
const source = sources.get(id);
if (!source) return;
sources.delete(id);
clearTimeout(source.timer);
source.child?.kill('SIGTERM');
videoSessions.revokeWhere((info) => ['roomAudio', 'roomMic'].includes(info.sourceType) && info.sourceId === id);
active = active.filter((entry) => entry.id !== id);
roomAudioEvents.emit('update');
}
function startWorker(source) {
if (sources.get(source.id) !== source) return;
const args = ['-nostdin', '-hide_banner', '-loglevel', 'warning'];
if (source.type === 'device') args.push('-f', 'alsa');
else if (/^rtsps?:/i.test(source.input)) args.push('-rtsp_transport', 'tcp', '-timeout', '10000000');
else args.push('-rw_timeout', '10000000');
args.push('-i', source.input, '-map', '0:a:0', '-vn', '-c:a', 'libopus', '-ar', '48000', '-ac', '1', '-b:a', '64k', '-f', 'rtsp', '-rtsp_transport', 'tcp', `rtsp://127.0.0.1:8554/room-audio/${source.id}`);
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
source.child = child;
child.stderr.on('data', (data) => logger.warn('Room audio %s: %s', source.name, String(data).trim()));
child.on('error', (error) => logger.warn('Room audio %s: %s', source.name, error.message));
child.on('close', () => {
if (sources.get(source.id) !== source) return;
source.child = null;
source.timer = setTimeout(() => startWorker(source), 2000);
});
}
function applyConfig(config = {}) {
const ids = (config.sources || []).map((source) => source.id);
if (new Set(ids).size !== ids.length) throw new Error('Room audio source IDs must be unique');
generation += 1;
enabled = Boolean(config.enabled);
const configured = new Map((config.sources || []).map((entry) => {
const source = { ...entry, id: `static-${entry.id}` };
return [source.id, source];
}));
for (const [id, source] of sources) {
const next = configured.get(id);
// Static-source edits should not interrupt browser microphones or restart
// unchanged captures. Disabling the feature still stops every source.
if (!enabled || (!source.socketId && (!next || next.type !== source.type || next.input !== source.input))) stopSource(id);
}
if (enabled) for (const [id, source] of configured) {
const existing = sources.get(id);
if (existing) existing.name = source.name;
else {
sources.set(id, source);
startWorker(source);
}
}
roomAudioEvents.emit('update');
}
async function listMediaMtxItems(collection) {
const items = [];
let page = 0;
let pageCount = 1;
while (page < pageCount) {
const response = await fetch(`http://127.0.0.1:9997/v3/${collection}/list?page=${page}`, { signal: AbortSignal.timeout(1500) });
if (!response.ok) throw new Error('MediaMTX unavailable');
const data = await response.json();
items.push(...(data.items || []));
pageCount = data.pageCount || 1;
page += 1;
}
return items;
}
// Advertise actual ready MediaMTX paths, rather than a browser reservation or
// an ffmpeg process that may still be trying to connect to its input.
async function refreshStreams() {
if ((!enabled && !mediaSessions.size) || polling) return;
polling = true;
const currentGeneration = generation;
let ready = new Set();
try {
// HTTP authorization is checked at connection time. Recheck established
// browser sessions too so lockdown, disable, and disconnect stop live audio.
const previousSessionIds = [...mediaSessions.keys()];
const sessions = await listMediaMtxItems('webrtcsessions');
const liveSessionIds = new Set(sessions.map((session) => session.id));
for (const session of sessions) {
if (!session.path?.startsWith('room-audio/')) continue;
const info = videoSessions.getSession(mediaSessions.get(session.id));
const socket = info && io.sockets.sockets.get(info.socketId);
const allowed = socket && (session.state === 'publish'
? info.sourceType === 'roomMic' && canPublish(socket, info.sourceId)
: info.sourceType === 'roomAudio' && canListen(socket));
if (!allowed) await fetch(`http://127.0.0.1:9997/v3/webrtcsessions/kick/${session.id}`, { method: 'POST', signal: AbortSignal.timeout(1500) });
}
for (const id of previousSessionIds) if (!liveSessionIds.has(id)) mediaSessions.delete(id);
} catch (error) {
logger.warn('Room audio permission refresh failed: %s', error.message);
}
// Permission API failures must not make ready streams disappear. Only path
// discovery determines the catalog used by playback and replay capture.
try {
const paths = await listMediaMtxItems('paths');
for (const path of paths) if (path.ready) ready.add(path.name);
} catch { ready = new Set(); }
finally { polling = false; }
if (generation !== currentGeneration) return;
const next = [...sources.values()].filter((source) => ready.has(`room-audio/${source.id}`)).map(({ id, name }) => ({ id, name }));
if (JSON.stringify(next) !== JSON.stringify(active)) {
active = next;
roomAudioEvents.emit('update');
}
}
io.on('connection', (socket) => {
socket.on('roomAudio:start', (_payload, cb = () => {}) => {
if (!enabled || !isLocalNetwork(getSocketIp(socket))) return cb({ error: 'Room microphone publishing requires a local visitor and enabled room audio.' });
// One owned path per socket; retries replace credentials without accumulating streams.
const id = `mic-${socket.id}`;
videoSessions.revokeWhere((info) => info.sourceType === 'roomMic' && info.sourceId === id);
sources.set(id, { id, name: 'Room microphone', socketId: socket.id });
const token = videoSessions.createSession(socket, { type: 'roomMic', id });
cb({ id, token, url: `/video/room-audio/${id}/whip` });
});
socket.on('roomAudio:release', ({ token } = {}) => {
const info = videoSessions.getSession(token);
if (info?.socketId === socket.id && info.sourceType === 'roomAudio') videoSessions.revokeSession(token);
});
socket.on('roomAudio:stop', (_payload, cb = () => {}) => {
stopSource(`mic-${socket.id}`);
cb({ success: true });
});
socket.on('disconnect', () => {
stopSource(`mic-${socket.id}`);
});
});
applyConfig(loadConfig().roomAudio);
registerConfigurationHandler('roomAudio', applyConfig);
const pollTimer = setInterval(refreshStreams, 2000);
pollTimer.unref();
process.once('exit', () => { for (const source of sources.values()) source.child?.kill('SIGTERM'); });
module.exports = { getActiveRoomAudioStreams, trackMediaSession, roomAudioEvents, getRoomAudioStreams, canListen, canPublish, hasStream };
@@ -2,20 +2,19 @@
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state. // Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus. // Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { issueCommand } = require('../commandService');
const { sendAlert } = require('../alertService'); const { sendAlert } = require('../alertService');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor'); const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
const { createHelpHornNotifier } = require('./hornNotifier'); const { createHelpHornNotifier } = require('./hornNotifier');
const logger = require('../../globals/logger').child('roverHelpService');
const HELP_ALERT_COLOR = '#ef4444'; const HELP_ALERT_COLOR = '#ef4444';
const hornNotifier = createHelpHornNotifier({ const hornNotifier = createHelpHornNotifier({
getRover: (roverId) => roverManager.rovers.get(String(roverId)), getRover: (roverId) => roverManager.rovers.get(String(roverId)),
// commandService imports roverManager, so resolving it only when a chirp is issueCommand,
// actually issued avoids turning server startup order into a circular module logger,
// dependency while retaining the established command transport.
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
logger: require('../../globals/logger').child('roverHelpService'),
}); });
const monitor = createRoverHelpMonitor({ const monitor = createRoverHelpMonitor({
@@ -42,6 +41,11 @@ const monitor = createRoverHelpMonitor({
}); });
} else if (wasNeedingHelp && !needsHelp) { } else if (wasNeedingHelp && !needsHelp) {
hornNotifier.stop(roverId); hornNotifier.stop(roverId);
try {
issueCommand(roverId, { type: 'song', song: { notes: [{ note: 72, duration: 8 }, { note: 79, duration: 16 }] } });
} catch (err) {
logger.warn('Failed to play HELP-cleared confirmation', { roverId, error: err.message });
}
publishEvent({ publishEvent({
source: 'roverHelpService', source: 'roverHelpService',
type: 'rover.helpCleared', type: 'rover.helpCleared',
@@ -174,6 +174,8 @@ function createRoverLifecycle(deps) {
function canLeaveCurrentRover(socket, options = {}) { function canLeaveCurrentRover(socket, options = {}) {
const currentId = getPrimaryRoverForSocket(socket.id); const currentId = getPrimaryRoverForSocket(socket.id);
// Admins, including lockdown admins, can always leave their current rover.
if (isAdmin(socket)) return { ok: true, currentId };
/* /*
Leaving a rover is only risky when this socket is the last person attached Leaving a rover is only risky when this socket is the last person attached
to an undocked rover. The same rule is used for rover-to-rover switching to an undocked rover. The same rule is used for rover-to-rover switching
@@ -1,6 +1,6 @@
// Restricted Container Lifecycle Controller // Restricted Container Lifecycle Controller
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API. // Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts no caller-selected targets. // Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts tags only within the deployment-configured repository.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const http = require('http'); const http = require('http');
@@ -9,6 +9,12 @@ const Docker = require('dockerode');
const docker = new Docker({ socketPath: '/var/run/docker.sock' }); const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const TARGET_CONTAINER_NAME = 'multirover'; const TARGET_CONTAINER_NAME = 'multirover';
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest'; const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
// A registry port is not a tag separator. Strip a digest before splitting the tag.
const configuredName = TARGET_IMAGE.split('@')[0];
const tagSeparator = configuredName.lastIndexOf(':');
const hasConfiguredTag = tagSeparator > configuredName.lastIndexOf('/');
const IMAGE_REPOSITORY = hasConfiguredTag ? configuredName.slice(0, tagSeparator) : configuredName;
const DEFAULT_TAG = TARGET_IMAGE.includes('@') ? null : (hasConfiguredTag ? configuredName.slice(tagSeparator + 1) : 'latest');
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock'; const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
// Status belongs beside the controller's private socket instead of in the // Status belongs beside the controller's private socket instead of in the
// application's data volume. The controller runs as root for Docker access; // application's data volume. The controller runs as root for Docker access;
@@ -20,6 +26,32 @@ const HEALTH_POLL_MS = 1000;
let operationRunning = false; let operationRunning = false;
let status = readStatus(); let status = readStatus();
// A repository change in Compose invalidates the previous repository's selection.
if (status.selectedRepository !== IMAGE_REPOSITORY) {
status.selectedRepository = IMAGE_REPOSITORY;
status.selectedTag = DEFAULT_TAG;
}
function selectedImage() {
return status.selectedTag ? `${IMAGE_REPOSITORY}:${status.selectedTag}` : TARGET_IMAGE;
}
function publicStatus() {
return {
...status,
configuredImage: TARGET_IMAGE,
repository: IMAGE_REPOSITORY,
targetImage: selectedImage(),
};
}
function requestedImage(tag) {
if (tag === null) return { imageReference: selectedImage(), tag: status.selectedTag };
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
throw new Error('Enter a valid container tag (1–128 letters, numbers, underscores, periods, or hyphens).');
}
return { imageReference: `${IMAGE_REPOSITORY}:${tag}`, tag };
}
function readStatus() { function readStatus() {
try { try {
@@ -52,11 +84,12 @@ function readStatus() {
} }
function writeStatus(patch) { function writeStatus(patch) {
status = { ...status, ...patch }; const nextStatus = { ...status, ...patch };
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true }); fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
const temporaryPath = `${STATUS_PATH}.tmp`; const temporaryPath = `${STATUS_PATH}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); fs.writeFileSync(temporaryPath, `${JSON.stringify(nextStatus, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(temporaryPath, STATUS_PATH); fs.renameSync(temporaryPath, STATUS_PATH);
status = nextStatus;
return status; return status;
} }
@@ -68,23 +101,23 @@ async function inspectTargetContainer() {
return docker.getContainer(TARGET_CONTAINER_NAME).inspect(); return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
} }
async function inspectLocalTargetImage() { async function inspectLocalTargetImage(imageReference = selectedImage()) {
try { try {
return await docker.getImage(TARGET_IMAGE).inspect(); return await docker.getImage(imageReference).inspect();
} catch (error) { } catch (error) {
if (error.statusCode === 404) return null; if (error.statusCode === 404) return null;
throw error; throw error;
} }
} }
async function pullTargetImage() { async function pullTargetImage(imageReference = selectedImage()) {
const stream = await new Promise((resolve, reject) => { const stream = await new Promise((resolve, reject) => {
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream))); docker.pull(imageReference, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
}); });
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve())); docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
}); });
const image = await inspectLocalTargetImage(); const image = await inspectLocalTargetImage(imageReference);
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.'); if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
return image; return image;
} }
@@ -193,13 +226,14 @@ async function refreshImageStatus() {
]); ]);
return writeStatus({ return writeStatus({
runningImage: container.Image, runningImage: container.Image,
runningImageReference: container.Config?.Image || null,
availableImage: image?.Id || null, availableImage: image?.Id || null,
updateAvailable: image ? container.Image !== image.Id : null, updateAvailable: image ? container.Image !== image.Id : null,
}); });
} }
async function runCheck() { async function runCheck() {
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` }); writeStatus({ message: `Pulling ${selectedImage()}…` });
await pullTargetImage(); await pullTargetImage();
const current = await refreshImageStatus(); const current = await refreshImageStatus();
return current.updateAvailable ? 'An application update is available.' : 'The application is current.'; return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
@@ -213,9 +247,9 @@ async function runRestart() {
return 'The application container restarted successfully.'; return 'The application container restarted successfully.';
} }
async function runUpdate() { async function runUpdate({ imageReference, tag }) {
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null }); writeStatus({ message: `Pulling ${imageReference}…`, rollback: null });
const targetImage = await pullTargetImage(); const targetImage = await pullTargetImage(imageReference);
const previousContainer = await inspectTargetContainer(); const previousContainer = await inspectTargetContainer();
const previousImage = previousContainer.Image; const previousImage = previousContainer.Image;
let replacementStarted = false; let replacementStarted = false;
@@ -228,11 +262,15 @@ async function runUpdate() {
updateAvailable: previousImage !== targetImage.Id, updateAvailable: previousImage !== targetImage.Id,
}); });
replacementStarted = true; replacementStarted = true;
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE }); await replaceContainer({ template: previousContainer, imageReference: targetImage.Id });
writeStatus({ message: 'Waiting for the updated application to become healthy…' }); writeStatus({ message: 'Waiting for the updated application to become healthy…' });
const healthy = await waitForHealthyContainer(); const healthy = await waitForHealthyContainer();
writeStatus({ writeStatus({
// Commit the tag only once the replacement passes its health check.
selectedRepository: IMAGE_REPOSITORY,
selectedTag: tag,
runningImage: healthy.Image, runningImage: healthy.Image,
runningImageReference: healthy.Config?.Image || null,
availableImage: targetImage.Id, availableImage: targetImage.Id,
updateAvailable: false, updateAvailable: false,
}); });
@@ -284,7 +322,7 @@ function queueOperation(operation, runner) {
operationRunning = false; operationRunning = false;
} }
}, 500); }, 500);
return status; return publicStatus();
} }
function sendJson(response, statusCode, value) { function sendJson(response, statusCode, value) {
@@ -294,10 +332,11 @@ function sendJson(response, statusCode, value) {
const server = http.createServer((request, response) => { const server = http.createServer((request, response) => {
try { try {
const url = new URL(request.url, 'http://localhost');
if (request.method === 'GET' && request.url === '/status') { if (request.method === 'GET' && request.url === '/status') {
refreshImageStatus() refreshImageStatus()
.catch(() => status) .catch(() => status)
.then((current) => sendJson(response, 200, { available: true, ...current })); .then(() => sendJson(response, 200, { available: true, ...publicStatus() }));
return; return;
} }
if (request.method === 'POST' && request.url === '/check') { if (request.method === 'POST' && request.url === '/check') {
@@ -308,8 +347,10 @@ const server = http.createServer((request, response) => {
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) }); sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
return; return;
} }
if (request.method === 'POST' && request.url === '/update') { if (request.method === 'POST' && url.pathname === '/update') {
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) }); const target = requestedImage(url.searchParams.get('tag'));
queueOperation('update', () => runUpdate(target));
sendJson(response, 202, { available: true, ...publicStatus() });
return; return;
} }
sendJson(response, 404, { error: 'Lifecycle operation not found.' }); sendJson(response, 404, { error: 'Lifecycle operation not found.' });
@@ -88,14 +88,14 @@ io.on('connection', (socket) => {
.catch((error) => cb({ error: error.message, code: error.code || null })); .catch((error) => cb({ error: error.message, code: error.code || null }));
}); });
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => { socket.on('server:updateApplication', (payload = {}, cb = () => {}) => {
Promise.resolve() Promise.resolve()
.then(() => requireRecentPassword(socket)) .then(() => requireRecentPassword(socket))
.then(() => lifecycleClient.updateApplication()) .then(() => lifecycleClient.updateApplication(payload?.tag))
.then((lifecycle) => { .then((lifecycle) => {
// Wait for controller acceptance so rejected updates do not kick users. // Wait for controller acceptance so rejected updates do not kick users.
notifyDriversOfRestart(actorFor(socket)); notifyDriversOfRestart(actorFor(socket));
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {}); database.recordAuditEvent(actorFor(socket), 'application.update-requested', { tag: payload?.tag ?? lifecycle.selectedTag });
io.emit('server:restarting', { reason: 'application-update' }); io.emit('server:restarting', { reason: 'application-update' });
cb({ success: true, lifecycle }); cb({ success: true, lifecycle });
}) })
@@ -1,6 +1,6 @@
// Container Lifecycle Client // Container Lifecycle Client
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket. // Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
// Scope: This client cannot select images, containers, or Docker arguments; the controller owns those constants. // Scope: This client can request a tag; the controller owns the repository, container, and Docker arguments.
const http = require('http'); const http = require('http');
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock'; const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
@@ -68,5 +68,11 @@ module.exports = {
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }), checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
getLifecycleStatus, getLifecycleStatus,
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }), restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }), updateApplication: (tag) => {
if (tag !== undefined && typeof tag !== 'string') throw new Error('Container tag must be a string.');
return requestLifecycleController({
method: 'POST',
path: tag === undefined ? '/update' : `/update?tag=${encodeURIComponent(tag)}`,
});
},
}; };
+26 -8
View File
@@ -12,6 +12,7 @@ const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService'); const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display'); const { buildRoverTurn } = require('../turnService/display');
const { getRoomAudioStreams, roomAudioEvents } = require('../roomAudioService');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService'); const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const { const {
getPublicState: getPtzCameraState, getPublicState: getPtzCameraState,
@@ -186,6 +187,7 @@ function buildSession(socket) {
socketId: socket?.id || null, socketId: socket?.id || null,
role: getRole(socket), role: getRole(socket),
operatingMode: getOperatingMode(socket), operatingMode: getOperatingMode(socket),
canLeaveRover: roverManager.canLeaveCurrentRover(socket).ok,
mode: getMode(), mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)), isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount), bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
@@ -214,6 +216,7 @@ function buildSession(socket) {
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null, status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
}, },
roomCameras: getRoomCameras(), roomCameras: getRoomCameras(),
roomAudioStreams: getRoomAudioStreams(socket),
ptzCamera: getPtzCameraState(socket), ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(), homeAssistant: getHomeAssistantState(),
homeAssistantActivities: getActivityState(), homeAssistantActivities: getActivityState(),
@@ -261,10 +264,14 @@ function buildSession(socket) {
}; };
} }
// Cache only the published decision so sensor frames send updates on transitions.
const leavePermissionBySocket = new WeakMap();
function syncSocket(socket) { function syncSocket(socket) {
if (!socket) return; if (!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);
leavePermissionBySocket.set(socket, payload.canLeaveRover);
socket.emit('session:sync', payload); socket.emit('session:sync', payload);
} }
@@ -285,6 +292,15 @@ io.on('connection', (socket) => {
syncSocket(socket); syncSocket(socket);
}); });
managerEvents.on('sensor', ({ roverId }) => {
io.sockets.sockets.forEach((socket) => {
const decision = roverManager.canLeaveCurrentRover(socket);
if (decision.currentId !== roverId) return;
if (leavePermissionBySocket.get(socket) === decision.ok) return;
syncSocket(socket);
});
});
operatingModeEvents.on('change', () => syncAll()); operatingModeEvents.on('change', () => syncAll());
roleEvents.on('change', ({ socket }) => { roleEvents.on('change', ({ socket }) => {
@@ -363,13 +379,13 @@ privateRoverAccessRequestEvents.on('change', (event = {}) => {
syncAll(); syncAll();
}); });
managerEvents.on('driver', ({ socketId }) => { // Update the changed driver and anyone whose departure permission depends on them.
if (!socketId) return; managerEvents.on('driver', ({ socketId, roverId }) => {
const socket = io.sockets.sockets.get(socketId); io.sockets.sockets.forEach((socket) => {
if (socket) { if (socket.id === socketId || roverManager.isDriver(roverId, socket)) {
logger.info('Driver assignment change; syncing session', socketId); syncSocket(socket);
syncSocket(socket); }
} });
}); });
turnEvents.on('activeDriver', () => { turnEvents.on('activeDriver', () => {
@@ -407,6 +423,8 @@ turnEvents.on('queue', (event = {}) => {
syncAll(); syncAll();
}); });
roomAudioEvents.on('update', () => syncAll());
roomCameraEvents.on('update', () => { roomCameraEvents.on('update', () => {
logger.info('Room camera change detected; syncing all clients'); logger.info('Room camera change detected; syncing all clients');
syncAll(); syncAll();
@@ -459,7 +477,7 @@ balanceBoardEvents.on('change', () => {
}); });
replayEvents.on('update', () => { replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients'); logger.info('Replay state updated; syncing all clients');
syncAll(); syncAll();
}); });
@@ -11,6 +11,7 @@ function registerVideoAuthRoute(deps) {
logAdminEvent, logAdminEvent,
extractStreamInfoFromBody, extractStreamInfoFromBody,
canAccessStream, canAccessStream,
trackRoomAudioSession,
} = deps; } = deps;
app.post('/mediamtx/auth', (req, res) => { app.post('/mediamtx/auth', (req, res) => {
@@ -36,7 +37,8 @@ function registerVideoAuthRoute(deps) {
const isLoopback = ip === '127.0.0.1' || ip === '::1'; const isLoopback = ip === '127.0.0.1' || ip === '::1';
const isRoverForwardAudioRead = action === 'read' const isRoverForwardAudioRead = action === 'read'
&& isRtspProtocol && isRtspProtocol
&& streamInfo?.id?.endsWith('-fwd'); && streamInfo?.type === 'rover'
&& streamInfo.id.endsWith('-fwd');
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do // Replay and snapshot workers read MediaMTX through loopback RTSP. They do
// not represent a browser session. Rovers likewise read their dedicated // not represent a browser session. Rovers likewise read their dedicated
// -fwd speaker feed without browser credentials; every other non-loopback // -fwd speaker feed without browser credentials; every other non-loopback
@@ -50,7 +52,7 @@ function registerVideoAuthRoute(deps) {
callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads
under the existing session and role checks below. under the existing session and role checks below.
*/ */
if (action === 'publish' && isRtspProtocol) { if (action === 'publish' && isRtspProtocol && (streamInfo?.type !== 'roomAudio' || isLoopback)) {
return res.status(200).end(); return res.status(200).end();
} }
@@ -62,7 +64,9 @@ function registerVideoAuthRoute(deps) {
const info = videoSessions.getSession(sessionId); const info = videoSessions.getSession(sessionId);
const streamTypeMatches = const streamTypeMatches =
info && info &&
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover')); (info.sourceType === streamInfo.type
|| (info.sourceType === 'roomMic' && streamInfo.type === 'roomAudio')
|| (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) { 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();
@@ -78,6 +82,7 @@ function registerVideoAuthRoute(deps) {
return res.status(401).end(); return res.status(401).end();
} }
if (streamInfo.type === 'roomAudio' && protocol === 'webrtc') trackRoomAudioSession(body.id, sessionId);
return res.status(200).end(); return res.status(200).end();
}); });
} }
@@ -4,6 +4,7 @@
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('videoAuth'); const logger = require('../../globals/logger').child('videoAuth');
const roomAudioService = require('../roomAudioService');
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');
@@ -28,6 +29,7 @@ const { canAccessStream } = createVideoAuthPolicy({
turnService, turnService,
roverManager, roverManager,
ptzCameraService, ptzCameraService,
roomAudioService,
getSocketIp, getSocketIp,
isLocalNetwork, isLocalNetwork,
io, io,
@@ -42,6 +44,7 @@ registerVideoAuthRoute({
logAdminEvent, logAdminEvent,
extractStreamInfoFromBody, extractStreamInfoFromBody,
canAccessStream, canAccessStream,
trackRoomAudioSession: roomAudioService.trackMediaSession,
}); });
module.exports = {}; module.exports = {};
@@ -17,6 +17,7 @@ function createVideoAuthPolicy(deps) {
turnService, turnService,
roverManager, roverManager,
ptzCameraService, ptzCameraService,
roomAudioService,
getSocketIp, getSocketIp,
isLocalNetwork, isLocalNetwork,
io, io,
@@ -58,6 +59,11 @@ function createVideoAuthPolicy(deps) {
} }
function canAccessStream({ socket, streamInfo, action, sourceType }) { function canAccessStream({ socket, streamInfo, action, sourceType }) {
if (streamInfo.type === 'roomAudio') {
return action === 'publish'
? sourceType === 'roomMic' && roomAudioService.canPublish(socket, streamInfo.id)
: action === 'read' && sourceType === 'roomAudio' && roomAudioService.canListen(socket) && roomAudioService.hasStream(streamInfo.id);
}
if (!canView(socket)) { if (!canView(socket)) {
return false; return false;
} }
@@ -33,6 +33,8 @@ function extractStreamInfo(path) {
return { type: 'rover', id: rawId, baseId }; return { type: 'rover', id: rawId, baseId };
} }
if (remaining.length === 2 && remaining[0] === 'room-audio') return { type: 'roomAudio', id: remaining[1] };
if (remaining.length === 2 && remaining[0] === 'room') { if (remaining.length === 2 && remaining[0] === 'room') {
return { type: 'room', id: remaining[1] || '' }; return { type: 'room', id: remaining[1] || '' };
} }
@@ -18,7 +18,9 @@ const {
function buildWhepUrlForSource(source) { function buildWhepUrlForSource(source) {
const segments = []; const segments = [];
if (source.type === 'room') { if (source.type === 'roomAudio') {
segments.push('room-audio', encodeURIComponent(source.id));
} else if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id)); segments.push('room', encodeURIComponent(source.id));
} else if (source.type === 'ptz') { } else if (source.type === 'ptz') {
/* /*
@@ -145,6 +147,9 @@ io.on('connection', (socket) => {
*/ */
throw new Error('Live video is limited to this rover queue'); throw new Error('Live video is limited to this rover queue');
} }
} else if (target.type === 'roomAudio') {
const roomAudio = require('../roomAudioService');
if (!roomAudio.canListen(socket) || !roomAudio.hasStream(target.id)) throw new Error('Room audio unavailable');
} 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 if (target.type === 'ptz') { } else if (target.type === 'ptz') {
+4
View File
@@ -19,8 +19,12 @@ Jun 18 15:14:18 roombaserver.local node[216731]: Node.js v22.20.0
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'. Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'.
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.` Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.`
1. redo new user experience, at the very least make it a fullscreen thing instead of a popup
2. roverd settings and installer rework (see discord forum post)
3. global quiet mode with its fingers in everything. activity controls optional, session status for theater, another global volume set, etc.
# relative pipe dreams: # relative pipe dreams:
1. convert MRR into a platform that could work with non-roomba rovers...
1. VPS video forwarding 1. VPS video forwarding
1. get forwarding working with the VPS for in-queue users and spectators 1. get forwarding working with the VPS for in-queue users and spectators
2. bandwidth testing 2. bandwidth testing
+2
View File
@@ -4,7 +4,9 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"predev": "node scripts/prepareMediaMtx.js",
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite", "dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"prebuild": "node scripts/prepareMediaMtx.js",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js", "test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
+45
View File
@@ -0,0 +1,45 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
const version = '1.15.3'; // Matches the Dockerfile's default MediaMTX version.
const baseUrl = `https://raw.githubusercontent.com/bluenviron/mediamtx/v${version}/`;
const output = new URL('../.cache/mediamtx/publisher.js', import.meta.url);
const moduleHash = 'e903555223f196aca86d7adce5cf4437824435291bf9b47f284aa767fffabaaa';
const hash = (content) => createHash('sha256').update(content).digest('hex');
async function download(path, checksum) {
const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`MediaMTX download failed: ${path} (${response.status})`);
const content = await response.text();
if (hash(content) !== checksum) throw new Error(`MediaMTX checksum mismatch: ${path}`);
return content;
}
let cached;
try {
cached = await readFile(output, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (cached && hash(cached) === moduleHash) {
console.info(`Using cached MediaMTX publisher v${version}`);
} else {
const [source, license] = await Promise.all([
download('internal/servers/webrtc/publisher.js', '6b4448939b45c30507bce59acaf434e3133e6efac3bd77d7e20fb01462958509'),
download('LICENSE', 'ecae73b0a23185a35a1222edc0aae5245bb9739420bad0125b0c42e935301a80'),
]);
// Keep upstream behavior and its license; only adapt the browser-global export for Vite.
const module = `// MediaMTX v${version} publisher client, matching the Dockerfile default.\n`
+ `// Source: ${baseUrl}internal/servers/webrtc/publisher.js\n`
+ '// Only modification: export the class as an ES module for Vite.\n'
+ `/*\n${license}\n*/\n`
+ source.replace('window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;', 'export default MediaMTXWebRTCPublisher;');
if (hash(module) !== moduleHash) throw new Error('Generated MediaMTX module checksum mismatch');
await mkdir(new URL('.', output), { recursive: true });
// Publish the verified module atomically so an interrupted download cannot poison the cache.
const temporary = new URL(`${output.href}.${process.pid}.tmp`);
await writeFile(temporary, module);
await rename(temporary, output);
console.info(`Downloaded and verified MediaMTX publisher v${version}`);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adm
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication'); export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus'); export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate'); export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication'); export const updateApplication = (socket, tag) => emitAdminRequest(socket, 'server:updateApplication', { tag });
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status'); export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup'); export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload'); export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
+28 -3
View File
@@ -21,6 +21,7 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
const [restartRequested, setRestartRequested] = useState(false); const [restartRequested, setRestartRequested] = useState(false);
const [lifecycle, setLifecycle] = useState(null); const [lifecycle, setLifecycle] = useState(null);
const [lifecycleError, setLifecycleError] = useState(''); const [lifecycleError, setLifecycleError] = useState('');
const [containerTag, setContainerTag] = useState(null);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -90,10 +91,13 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
} }
async function update() { async function update() {
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return; const tag = containerTag === null ? undefined : containerTag.trim();
const target = tag === undefined ? lifecycle.targetImage : `${lifecycle.repository}:${tag}`;
if (!window.confirm(`Pull ${target} and replace the application container now? The page will reconnect automatically.`)) return;
try { try {
const response = await runSensitive(() => updateApplication(socket)); const response = await runSensitive(() => updateApplication(socket, tag));
setLifecycle(response.lifecycle); setLifecycle(response.lifecycle);
setContainerTag(null);
} catch (error) { } catch (error) {
setLifecycleError(error.message); setLifecycleError(error.message);
} }
@@ -121,15 +125,36 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm"> <CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300"> <div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p> <p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
{lifecycleAvailable ? (
<div className="space-y-0.5 break-all">
<p>Repository: {lifecycle.repository}</p>
<p>Selected image: {lifecycle.targetImage}</p>
<p>Running image: {lifecycle.runningImage}</p>
<p>Compose image: {lifecycle.configuredImage}</p>
{lifecycle.targetImage !== lifecycle.configuredImage ? <p className="text-amber-300">A manual Compose deployment may restore the Compose image.</p> : null}
</div>
) : null}
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null} {lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null} {lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null}
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null} {lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
</div> </div>
<label className="flex max-w-md flex-col gap-0.5">
<span>Container tag</span>
<input
className="field-input w-full"
value={containerTag ?? lifecycle?.selectedTag ?? ''}
placeholder="Enter a tag"
maxLength={128}
disabled={!lifecycleAvailable || lifecycleBusy}
onChange={(event) => setContainerTag(event.target.value)}
/>
<span className="text-xs text-slate-400">Update and restart applies this tag. Update checks use the saved selected image.</span>
</label>
<div className="flex flex-wrap gap-0.5"> <div className="flex flex-wrap gap-0.5">
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}> <button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'} {lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
</button> </button>
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}> <button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy || (containerTag !== null && !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(containerTag.trim()))} onClick={update}>
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'} {lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
</button> </button>
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}> <button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
@@ -59,20 +59,6 @@ function BarcodeScanToast({ payload }) {
); );
} }
function UndockedExitWarningToast() {
return (
<div
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
export default function AlertContent({ alert }) { export default function AlertContent({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) { if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload; const payload = alert.payload;
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
if (alert.kind === 'barcode-scan' && alert.payload) { if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />; return <BarcodeScanToast payload={alert.payload} />;
} }
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
const color = typeof alert.color === 'string' ? alert.color.trim() : ''; const color = typeof alert.color === 'string' ? alert.color.trim() : '';
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3'; const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
+51
View File
@@ -0,0 +1,51 @@
import { Component } from 'react';
export default class AppErrorBoundary extends Component {
state = { crashed: false, error: null, componentStack: '', copyStatus: '' };
static getDerivedStateFromError(error) {
return { crashed: true, error };
}
componentDidCatch(error, info) {
this.setState({ componentStack: info.componentStack || '' });
}
getDetails() {
const { error, componentStack } = this.state;
return [
String(error?.message ?? error ?? 'Unknown error'),
'JavaScript stack:',
error?.stack || 'Unavailable',
'React component stack:',
componentStack || 'Unavailable',
].join('\n\n');
}
copyDetails = async () => {
try {
await navigator.clipboard.writeText(this.getDetails());
this.setState({ copyStatus: 'Copied.' });
} catch {
// Clipboard access may be unavailable on the display device; the text
// remains selectable so the error can still be copied manually.
this.setState({ copyStatus: 'Copy unavailable. Select the details below to copy manually.' });
}
};
render() {
if (!this.state.crashed) return this.props.children;
// Keep recovery independent of providers and shared UI components: one of
// those dependencies may be the reason the application failed.
return (
<main style={{ backgroundColor: '#b2339f', minHeight: '100vh' }}>
<h1>Application crashed</h1>
<button type="button" onClick={this.copyDetails}>Copy details</button>
<button type="button" onClick={() => window.location.reload()}>Reload</button>
{this.state.copyStatus ? <p role="status">{this.state.copyStatus}</p> : null}
<pre>{this.getDetails()}</pre>
</main>
);
}
}
@@ -23,14 +23,29 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts /* The dismissal belongs to this mounted docked episode. DockingHud unmounts
this component when the rover leaves the base, and its roverId key remounts this component when the rover leaves the base, and its roverId key remounts
it for a different assignment, so no persistence or reset effect is needed. */ it for a different assignment, so no persistence or reset effect is needed. */
if (hidden && !pending) return null;
const mainToneClass = waitingForTurn const mainToneClass = waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70' ? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75'; : 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75';
const hideToneClass = waitingForTurn const hideToneClass = waitingForTurn
? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900' ? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200'; : 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200';
const toggleClass = `pointer-events-auto absolute left-1/2 -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${hideToneClass}`;
// Keep a small reopen control on the video's top edge; pending undocking
// still reveals the prompt so progress remains visible after a keybind action.
if (hidden && !pending) {
return (
<button
type="button"
aria-label="Show docked rover controls"
aria-expanded={false}
onClick={() => setHidden(false)}
className={`${toggleClass} top-0 z-30`}
>
Show
</button>
);
}
return ( return (
<> <>
@@ -71,8 +86,10 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
{!pending ? ( {!pending ? (
<button <button
type="button" type="button"
aria-label="Hide docked rover controls"
aria-expanded={true}
onClick={() => setHidden(true)} onClick={() => setHidden(true)}
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${hideToneClass}`} className={`${toggleClass} top-full`}
> >
Hide Hide
</button> </button>
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { useBlocker } from 'react-router-dom';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function PageExitGuard() {
const shouldBlock = useSessionSelector((state) => state.session?.canLeaveRover === false);
const [warning, setWarning] = useState({ blocked: shouldBlock, visible: false });
// Clear an old exit attempt when server permission changes, so undocking
// again does not resurrect a warning from the previous trip.
if (warning.blocked !== shouldBlock) {
setWarning({ blocked: shouldBlock, visible: false });
}
const blocker = useBlocker(({ currentLocation, nextLocation }) => shouldBlock && (
currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search
));
useEffect(() => {
if (!shouldBlock) return undefined;
function handleBeforeUnload(event) {
// Commit before the native dialog pauses JavaScript. The browser still
// decides whether it repaints the document before displaying its dialog.
flushSync(() => setWarning({ blocked: true, visible: true }));
event.preventDefault();
event.returnValue = true;
}
function blockNavigation(event) {
event.preventDefault();
event.stopPropagation();
setWarning({ blocked: true, visible: true });
}
function handleLinkClick(event) {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!link || link.hasAttribute('download')) return;
const target = (link.getAttribute('target') || document.querySelector('base[target]')?.getAttribute('target') || '_self').toLowerCase();
if (target !== '_self') return;
const destination = new URL(link.href, window.location.href);
if (destination.protocol !== 'http:' && destination.protocol !== 'https:') return;
// Hash links stay in this document and do not abandon the rover.
if (destination.origin === window.location.origin
&& destination.pathname === window.location.pathname
&& destination.search === window.location.search
&& link.getAttribute('href').includes('#')) return;
blockNavigation(event);
}
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('click', handleLinkClick, true);
window.addEventListener('page:navigate', blockNavigation);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('click', handleLinkClick, true);
window.removeEventListener('page:navigate', blockNavigation);
};
}, [shouldBlock]);
useEffect(() => {
// Docking cancels the attempted navigation; the next attempt can proceed.
if (!shouldBlock && blocker.state === 'blocked') blocker.reset();
}, [shouldBlock, blocker]);
const visible = shouldBlock && (warning.visible || blocker.state === 'blocked');
if (!visible) return null;
function dismiss() {
setWarning({ blocked: shouldBlock, visible: false });
if (blocker.state === 'blocked') blocker.reset();
}
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="page-exit-warning"
className="fixed inset-0 z-[2147483647] flex flex-col items-center justify-between gap-4 bg-black p-6 text-center text-white"
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') dismiss();
}}
>
<p id="page-exit-warning" className="text-[clamp(3rem,10vw,10rem)] font-bold leading-tight">
Please dock your rover!
</p>
<button type="button" autoFocus className="button-dark px-8 py-4 text-3xl" onClick={dismiss}>
Okay
</button>
<p aria-hidden="true" className="text-[clamp(3rem,10vw,10rem)] font-bold leading-tight">
Please dock your rover!
</p>
</div>,
document.body,
);
}
+4 -3
View File
@@ -21,6 +21,7 @@ export default function PtzQueueCard() {
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera')); const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
const { setOperatingMode } = useSessionActions(); const { setOperatingMode } = useSessionActions();
const navigate = useNavigate(); const navigate = useNavigate();
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline)); const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
@@ -32,7 +33,7 @@ export default function PtzQueueCard() {
if (!featureEnabled) return null; if (!featureEnabled) return null;
const handleRequest = async () => { const handleRequest = async () => {
if (!canUse || pending) return; if (!canUse || !canLeaveRover || pending) return;
if (isParticipant) { if (isParticipant) {
navigate('/ptz'); navigate('/ptz');
return; return;
@@ -76,14 +77,14 @@ export default function PtzQueueCard() {
}} }}
turn={ptz?.turn} turn={ptz?.turn}
selfId={selfId} selfId={selfId}
canClick={canUse && !pending} canClick={canUse && canLeaveRover && !pending}
pending={pending} pending={pending}
buttonLabel={actionLabel} buttonLabel={actionLabel}
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'} batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'} batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
timerLabel={timerLabel} timerLabel={timerLabel}
onRequest={handleRequest} onRequest={handleRequest}
showAction={canUse} showAction={canUse && canLeaveRover}
/> />
</ul> </ul>
{isParticipant ? ( {isParticipant ? (
+4 -2
View File
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react';
import { useVideoRequests } from '../../hooks/useVideoRequests.js'; import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { startWhepPlayback } from '../../lib/whepPlayback.js'; import { startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js'; import useWhepRestart from '../../hooks/useWhepRestart.js';
import useServerUrl from '../../hooks/useServerUrl.js';
export const PTZ_CAMERA_ID = 'ptz-camera'; export const PTZ_CAMERA_ID = 'ptz-camera';
@@ -23,6 +24,7 @@ export default function PtzLiveVideo({
statusClassName = 'pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]', statusClassName = 'pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]',
fallback = null, fallback = null,
}) { }) {
const serverUrl = useServerUrl();
const videoRef = useRef(null); const videoRef = useRef(null);
const playTimerRef = useRef(null); const playTimerRef = useRef(null);
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
@@ -44,7 +46,7 @@ export default function PtzLiveVideo({
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined; if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
const video = videoRef.current; const video = videoRef.current;
const stop = startWhepPlayback({ const stop = startWhepPlayback({
url: source.url, url: serverUrl(source.url),
token: source.token, token: source.token,
video: videoRef.current, video: videoRef.current,
startMuted, startMuted,
@@ -76,7 +78,7 @@ export default function PtzLiveVideo({
video.removeEventListener('error', handleError); video.removeEventListener('error', handleError);
stop(); stop();
}; };
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]); }, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted, serverUrl]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -46,7 +46,7 @@ export default function ReplaySourcesPanel({
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null); const replayState = useSessionSelector((state) => state.session?.replay || null);
const replayStatus = useSessionSelector((state) => state.replayStatus); const replayStatus = replayState?.status;
const socket = useSocket(); const socket = useSocket();
const { triggerReplay } = useSessionActions(); const { triggerReplay } = useSessionActions();
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]); const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
@@ -161,48 +161,25 @@ export default function ReplaySourcesPanel({
}; };
}, [sources]); }, [sources]);
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs); const cooldownNow = useSharedClock(1000, Boolean(replayState?.lastTriggeredAt));
const replayCooldownEndsAt = hasReplayCooldown const remainingMs = replayState?.lastTriggeredAt
? replayState.lastTriggeredAt + replayState.cooldownMs ? Math.max(0, replayState.lastTriggeredAt + replayState.cooldownMs - cooldownNow)
: 0; : 0;
const cooldownNow = useSharedClock(1000, hasReplayCooldown); const atJobLimit = replayState?.atJobLimit === true;
const remainingMs = useMemo(() => { const replaySourceKeys = replayState?.activeSourceKeys;
if (!hasReplayCooldown) return 0; const activeSourceKeys = useMemo(() => new Set(replaySourceKeys || []), [replaySourceKeys]);
/* const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || atJobLimit || !selected.length;
The button only shows whole seconds, so a shared one-second clock gives the
same useful information without each mounted replay panel owning a 250ms
interval. The exact server cooldown still decides whether the action is
accepted; this value is only the local disabled-state/display estimate.
*/
const next = replayCooldownEndsAt - cooldownNow;
return Math.max(0, next);
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
const selectedSet = useMemo(() => { const selectedSet = useMemo(() => {
// Checkbox rendering asks the same membership question for every source. // Checkbox rendering asks the same membership question for every source.
// A Set avoids repeated linear scans and, more importantly, gives memoized // A Set avoids repeated linear scans and, more importantly, gives memoized
// child lists a stable value while the selected keys have not changed. // child lists a stable value while the selected keys have not changed.
return new Set(selected); return new Set(selected);
}, [selected]); }, [selected]);
const replayStatusText = useMemo(() => { const replayStatusText = replayStatus?.status
if (!replayStatus?.status) return null; ? replayStatus.status === 'failed' && replayStatus.message
const titleText = replayStatus.title ? `: ${replayStatus.title}` : ''; ? replayStatus.message
switch (replayStatus.status) { : `Replay ${replayStatus.status}${replayStatus.title ? `: ${replayStatus.title}` : ''}`
case 'accepted': : null;
return `Replay accepted${titleText}`;
case 'building':
return `Replay building${titleText}`;
case 'uploading':
return `Replay uploading${titleText}`;
case 'ready':
return `Replay ready${titleText}`;
case 'failed':
return replayStatus.message || `Replay failed${titleText}`;
default:
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
}
}, [replayStatus]);
const toggleKey = useCallback((key) => { const toggleKey = useCallback((key) => {
setSelected((prev) => { setSelected((prev) => {
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]; return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
@@ -250,13 +227,13 @@ export default function ReplaySourcesPanel({
current desktop sidebar. They only collapse when the card becomes current desktop sidebar. They only collapse when the card becomes
narrower than the supported new-drive desktop column. */} narrower than the supported new-drive desktop column. */}
<div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}> <div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}>
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} onToggle={toggleKey} /> <GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} onToggle={toggleKey} /> <GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{error ? <div className="text-xs text-amber-400">{error}</div> : null} {error ? <div className="text-xs text-amber-400">{error}</div> : null}
{replayStatusText ? ( {replayStatusText ? (
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}> <div className={`text-xs ${replayStatus.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
{replayStatusText} {replayStatusText}
</div> </div>
) : null} ) : null}
@@ -279,11 +256,6 @@ export default function ReplaySourcesPanel({
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next })); saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
// Enter is the keyboard equivalent of clicking Replay. Ignore
// composition events so confirming an IME candidate cannot
// accidentally submit a replay before the title is complete.
// handleReplay remains the single authority for cooldown,
// lockdown, busy, and empty-source checks.
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return; if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
event.preventDefault(); event.preventDefault();
handleReplay(); handleReplay();
@@ -314,7 +286,7 @@ export default function ReplaySourcesPanel({
onClick={handleReplay} onClick={handleReplay}
disabled={replayDisabled} disabled={replayDisabled}
> >
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'} {atJobLimit ? 'Replay (limit reached)' : remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
</button> </button>
</div> </div>
</div> </div>
@@ -323,14 +295,14 @@ export default function ReplaySourcesPanel({
); );
} }
const GroupList = React.memo(function GroupList({ title, items, selectedSet, onToggle }) { const GroupList = React.memo(function GroupList({ title, items, selectedSet, activeSourceKeys, onToggle }) {
if (!items.length) return null; if (!items.length) return null;
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="panel-muted text-xs">{title}</div> <div className="panel-muted text-xs">{title}</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{items.map((item) => ( {items.map((item) => (
<label key={item.key} className="surface flex items-center gap-0.5 text-xs"> <label key={item.key} className={`surface flex items-center gap-0.5 text-xs ${activeSourceKeys.has(item.key) ? 'animate-pulse' : ''}`}>
<input <input
type="checkbox" type="checkbox"
checked={selectedSet.has(item.key)} checked={selectedSet.has(item.key)}
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { RESTART_DELAY_MS, startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js';
import useServerUrl from '../../hooks/useServerUrl.js';
function StreamPlayer({ id, volume }) {
const socket = useSocket();
const serverUrl = useServerUrl();
const audio = useRef(null);
const { restartToken, scheduleRestart } = useWhepRestart();
useEffect(() => {
let active = true;
let stop;
let token;
socket.timeout(5000).emit('video:request', { type: 'roomAudio', id }, (error, response = {}) => {
if (!active) {
if (response.token && socket.connected) socket.emit('roomAudio:release', { token: response.token });
return;
}
token = response.token;
if (error || response.error || !response.url) { scheduleRestart(); return; }
stop = startWhepPlayback({
...response, url: serverUrl(response.url), video: audio.current, audioOnly: true,
onStatus: () => {}, onError: () => {}, scheduleRestart,
});
});
return () => {
active = false; stop?.();
if (token && socket.connected) socket.emit('roomAudio:release', { token });
};
}, [id, socket, restartToken, scheduleRestart, serverUrl]);
useEffect(() => { if (audio.current) audio.current.volume = Math.max(0, Math.min(1, volume)); }, [volume]);
return <audio ref={audio} autoPlay />;
}
export default function RoomAudioPlayer() {
const container = useRef(null);
const streams = useSessionSelector((state) => state.session?.roomAudioStreams);
const connected = useSessionSelector((state) => state.connected);
const { value } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const volume = (value?.masterVolume ?? 1) * (value?.roomVolume ?? 1);
const play = useCallback(() => {
const root = container.current;
if (!root) return;
const elements = [...root.querySelectorAll('audio')].filter((audio) => audio.srcObject);
if (!elements.length) return;
// Call play during the gesture; blocked playback retries on the interval.
elements.forEach((audio) => { audio.play().catch(() => {}); });
}, []);
useEffect(() => {
if (!connected) return undefined;
const timer = setInterval(play, RESTART_DELAY_MS);
window.addEventListener('pointerdown', play);
window.addEventListener('keydown', play);
return () => {
clearInterval(timer);
window.removeEventListener('pointerdown', play);
window.removeEventListener('keydown', play);
};
}, [connected, play]);
if (!connected) return null;
return (
<div ref={container} className="contents">
{(streams || []).map((stream) => <StreamPlayer key={stream.id} id={stream.id} volume={volume} />)}
</div>
);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { startWhepPlayback } from '../../lib/whepPlayback.js'; import { startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js'; import useWhepRestart from '../../hooks/useWhepRestart.js';
import useServerUrl from '../../hooks/useServerUrl.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx'; import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { import {
mainBrushAudioTelemetryEqual, mainBrushAudioTelemetryEqual,
@@ -130,6 +131,7 @@ export default function RoverMediaPlayer({
pauseVideoWhenHidden = null, pauseVideoWhenHidden = null,
}) { }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const serverUrl = useServerUrl();
const effectiveRoverId = roverId ?? assignedRoverId; const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
const configuredPauseHiddenRoverVideo = useSessionSelector( const configuredPauseHiddenRoverVideo = useSessionSelector(
@@ -415,7 +417,7 @@ export default function RoverMediaPlayer({
} }
const resetMuteId = setTimeout(() => setMuted(true), 0); const resetMuteId = setTimeout(() => setMuted(true), 0);
const stop = startWhepPlayback({ const stop = startWhepPlayback({
url: resolvedSessionInfo.url, url: serverUrl(resolvedSessionInfo.url),
token: resolvedSessionInfo.token, token: resolvedSessionInfo.token,
video: videoRef.current, video: videoRef.current,
receiveAudio: !hasDedicatedAudio, receiveAudio: !hasDedicatedAudio,
@@ -446,6 +448,7 @@ export default function RoverMediaPlayer({
hasDedicatedAudio, hasDedicatedAudio,
logAudio, logAudio,
effectiveRoverId, effectiveRoverId,
serverUrl,
]); ]);
useEffect(() => { useEffect(() => {
@@ -510,7 +513,7 @@ export default function RoverMediaPlayer({
return undefined; return undefined;
} }
return startWhepPlayback({ return startWhepPlayback({
url: resolvedAudioSessionInfo.url, url: serverUrl(resolvedAudioSessionInfo.url),
token: resolvedAudioSessionInfo.token, token: resolvedAudioSessionInfo.token,
video: audioRef.current, video: audioRef.current,
audioOnly: true, audioOnly: true,
@@ -533,6 +536,7 @@ export default function RoverMediaPlayer({
audioRestartToken, audioRestartToken,
scheduleAudioRestart, scheduleAudioRestart,
logAudio, logAudio,
serverUrl,
]); ]);
useEffect(() => { useEffect(() => {
@@ -101,6 +101,7 @@ export default function RoverQueuesPanel({
fillHeight = false, fillHeight = false,
}) { }) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
const localRoster = useSessionSelector((state) => state.session?.roster ?? []); const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance')); const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true }); const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
@@ -122,8 +123,8 @@ export default function RoverQueuesPanel({
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster; const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
const canRequest = useMemo( const canRequest = useMemo(
() => (externalMode ? !externalBlocked : role && role !== 'spectator'), () => canLeaveRover && (externalMode ? !externalBlocked : role && role !== 'spectator'),
[externalBlocked, externalMode, role], [canLeaveRover, externalBlocked, externalMode, role],
); );
const adminCapable = useMemo( const adminCapable = useMemo(
() => role === 'admin' || role === 'lockdown', () => role === 'admin' || role === 'lockdown',
@@ -142,7 +143,7 @@ export default function RoverQueuesPanel({
async function handleRequest(targetRoverId) { async function handleRequest(targetRoverId) {
if (!targetRoverId) return; if (!targetRoverId) return;
if (externalBlocked) return; if (!canRequest) return;
if (externalMode) { if (externalMode) {
/* /*
External queue cards deliberately reuse the local row layout, but their External queue cards deliberately reuse the local row layout, but their
@@ -252,7 +253,7 @@ export default function RoverQueuesPanel({
? lockLabel ? lockLabel
: externalMode : externalMode
? 'Open' ? 'Open'
: 'request'; : 'Switch';
const canClickRow = canRequest && !lockedBlocked && !pending[roverId]; const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
return ( return (
<QueueTargetRow <QueueTargetRow
@@ -159,6 +159,7 @@ export default function SettingsPanel() {
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume; const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume; const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume; const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
const roomVolume = Number.isFinite(audioSettings?.roomVolume) ? audioSettings.roomVolume : AUDIO_SETTINGS_DEFAULTS.roomVolume;
const mainBrushDuckEnabled = const mainBrushDuckEnabled =
typeof audioSettings?.mainBrushDuckEnabled === 'boolean' typeof audioSettings?.mainBrushDuckEnabled === 'boolean'
? audioSettings.mainBrushDuckEnabled ? audioSettings.mainBrushDuckEnabled
@@ -440,6 +441,11 @@ export default function SettingsPanel() {
value={roverVolume} value={roverVolume}
onChange={handleAudioRange('roverVolume')} onChange={handleAudioRange('roverVolume')}
/> />
<RangeSetting
label="Room audio"
value={roomVolume}
onChange={handleAudioRange('roomVolume')}
/>
<SettingRow> <SettingRow>
<span className="font-semibold text-white">Main brush ducking</span> <span className="font-semibold text-white">Main brush ducking</span>
<input <input
@@ -1,66 +0,0 @@
// Undocked Page Exit Guard
// Purpose: Asks the browser to confirm document-level navigation while the current driver's rover is undocked.
// Scope: Owns the native beforeunload lifecycle and queues the matching AlertFeed explanation when leaving is attempted.
import { useEffect } from 'react';
import { useControlSelector } from '../../controls/index.js';
import { useSessionActions } from '../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
const UNDOCKED_EXIT_ALERT_LIFETIME_MS = 15 * 1000;
function selectHomeBaseState(frame) {
const homeBase = frame?.sensors?.chargingSources?.homeBase;
/*
Missing telemetry must remain distinct from a real "not on the home base"
reading. Treating the initial empty frame as undocked would enable the exit
warning before the browser has received enough information to justify it.
*/
return typeof homeBase === 'boolean' ? homeBase : null;
}
export default function UndockedPageExitGuard() {
const roverId = useControlSelector((control) => control.state.roverId);
const homeBaseState = useTelemetrySelector(roverId, selectHomeBaseState);
const { pushAlert } = useSessionActions();
const shouldConfirmExit = Boolean(roverId) && homeBaseState === false;
useEffect(() => {
if (!shouldConfirmExit) return undefined;
function handleBeforeUnload(event) {
/*
Browsers intentionally own the wording and presentation of this dialog.
preventDefault is the modern signal, while assigning returnValue keeps
the confirmation working in browsers that still require the legacy part
of the beforeunload contract.
*/
event.preventDefault();
event.returnValue = true;
/*
If the driver cancels the browser-owned confirmation, React remains
mounted and AlertFeed presents the actionable reason for the warning.
A stable id refreshes this one warning lane on another exit attempt
instead of stacking duplicate cards over the driving interface.
*/
pushAlert({
id: 'undocked-page-exit-warning',
kind: 'undocked-exit-warning',
lifetimeMs: UNDOCKED_EXIT_ALERT_LIFETIME_MS,
});
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
/*
Removing the listener as soon as the rover docks or the assignment ends
prevents ordinary spectators and completed drivers from seeing a stale
leave-page confirmation.
*/
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [pushAlert, shouldConfirmExit]);
return null;
}
@@ -1,19 +1,16 @@
// Vip Audio Upload Card Content // Vip Audio Upload Card Content
// Purpose: Defines the Vip Audio Upload Card Content module and the local helpers/components used in this file. // Purpose: Defines the Vip Audio Upload Card Content module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fieldClass } from '../constants.js'; import { fieldClass } from '../constants.js';
import { useControlSelector } from '../../../controls/index.js'; import { useControlSelector } from '../../../controls/index.js';
import ControlHint from '../../ControlHint/index.jsx'; import ControlHint from '../../ControlHint/index.jsx';
import { useSettingsNamespace } from '../../../settings/index.js'; import { useSettingsNamespace } from '../../../settings/index.js';
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js'; import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE } from './constants.js';
import { bytesToBase64, buildAuthHeader } from './base64.js'; import { bytesToBase64 } from './base64.js';
import { import { waitForOutboundAudioFlow } from './whipTransport.js';
waitForIceGatheringComplete, import MediaMTXWebRTCPublisher from '../../../../.cache/mediamtx/publisher.js';
waitForPeerConnected, import useServerUrl from '../../../hooks/useServerUrl.js';
configureSenderForLowLatency,
waitForOutboundAudioFlow,
} from './whipTransport.js';
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js'; import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
import StatusIndicator from './StatusIndicator.jsx'; import StatusIndicator from './StatusIndicator.jsx';
import KeyPill from './KeyPill.jsx'; import KeyPill from './KeyPill.jsx';
@@ -28,6 +25,7 @@ export default function VipAudioUploadCard({
readyMicWhip, readyMicWhip,
stopMicWhip, stopMicWhip,
}) { }) {
const serverUrl = useServerUrl();
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive)); const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', { const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
openMicEnabled: false, openMicEnabled: false,
@@ -47,7 +45,8 @@ export default function VipAudioUploadCard({
const streamRef = useRef(null); const streamRef = useRef(null);
const audioTrackRef = useRef(null); const audioTrackRef = useRef(null);
const whipPcRef = useRef(null); const micAttemptRef = useRef(null);
const micEnabledRef = useRef(false);
const micActiveRef = useRef(false); const micActiveRef = useRef(false);
const activeRoverRef = useRef(''); const activeRoverRef = useRef('');
@@ -146,19 +145,12 @@ export default function VipAudioUploadCard({
const target = String(targetRoverId || activeRoverRef.current || '').trim(); const target = String(targetRoverId || activeRoverRef.current || '').trim();
micActiveRef.current = false; micActiveRef.current = false;
setMicState('idle'); setMicState('idle');
if (whipPcRef.current) { const publisher = micAttemptRef.current?.publisher;
try { micAttemptRef.current = null;
whipPcRef.current.getSenders().forEach((sender) => sender.track?.stop()); activeRoverRef.current = '';
} catch { publisher?.close();
// noop // The upstream client stops transport retries; release its HTTP session separately.
} if (publisher?.sessionUrl) fetch(publisher.sessionUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
try {
whipPcRef.current.close();
} catch {
// noop
}
}
whipPcRef.current = null;
if (streamRef.current) { if (streamRef.current) {
try { try {
streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current.getTracks().forEach((track) => track.stop());
@@ -175,14 +167,15 @@ export default function VipAudioUploadCard({
// noop // noop
} }
} }
activeRoverRef.current = '';
}, },
[stopMicWhip], [stopMicWhip],
); );
const startWhipMic = useCallback( const startWhipMic = useCallback(
async (target) => { async (target, attempt) => {
const isCurrent = () => micAttemptRef.current === attempt && activeRoverRef.current === target;
const startPayload = await startMicWhip?.(target); const startPayload = await startMicWhip?.(target);
if (!isCurrent()) return;
const whipUrl = String(startPayload?.whipUrl || '').trim(); const whipUrl = String(startPayload?.whipUrl || '').trim();
const token = String(startPayload?.token || '').trim(); const token = String(startPayload?.token || '').trim();
if (!whipUrl || !token) { if (!whipUrl || !token) {
@@ -198,12 +191,16 @@ export default function VipAudioUploadCard({
autoGainControl: false, autoGainControl: false,
}, },
}); });
if (!isCurrent()) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream; streamRef.current = stream;
const track = stream.getAudioTracks()?.[0]; const track = stream.getAudioTracks()?.[0];
audioTrackRef.current = track || null; audioTrackRef.current = track || null;
if (track) { if (track) {
track.enabled = Boolean(openMicEnabled || pttActive); track.enabled = micEnabledRef.current;
} }
if (track?.applyConstraints) { if (track?.applyConstraints) {
try { try {
@@ -219,47 +216,42 @@ export default function VipAudioUploadCard({
} }
} }
const pc = new RTCPeerConnection(RTC_CONFIG); if (!isCurrent()) return;
whipPcRef.current = pc; attempt.publisher = new MediaMTXWebRTCPublisher({
pc.onconnectionstatechange = () => { url: serverUrl(whipUrl),
if (!micActiveRef.current) return; user: token,
const state = pc.connectionState; pass: token,
if (state === 'connected') { stream,
setMicState('live'); audioCodec: 'opus',
return; audioBitrate: 64,
} audioVoice: true,
if (state === 'failed' || state === 'disconnected' || state === 'closed') { onConnected: async () => {
setMicState('error'); if (!isCurrent()) return;
setMessage(`WHIP transport ${state}.`); const pc = attempt.publisher.pc;
} try {
}; // Report ready only after audio is flowing, including on transport reconnection.
stream.getAudioTracks().forEach((audioTrack) => { await waitForOutboundAudioFlow(pc);
const sender = pc.addTrack(audioTrack, stream); if (!isCurrent() || attempt.publisher.pc !== pc) return;
configureSenderForLowLatency(sender); await readyMicWhip?.(target);
}); if (!isCurrent() || attempt.publisher.pc !== pc) return;
setMicState('live');
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false }); setMessage('');
await pc.setLocalDescription(offer); } catch (error) {
await waitForIceGatheringComplete(pc, 1800); if (!isCurrent() || attempt.publisher.pc !== pc) return;
await stopMicCapture(target);
const response = await fetch(whipUrl, { if (micAttemptRef.current) return;
method: 'POST', setMicState('error');
headers: { setMessage(error.message || 'Failed to start mic forwarding.');
'Content-Type': 'application/sdp', }
...buildAuthHeader(token), },
onError: (message) => {
if (!isCurrent()) return;
setMicState('starting');
setMessage(message);
}, },
body: pc.localDescription?.sdp || offer.sdp,
}); });
if (!response.ok) {
throw new Error(`WHIP request failed: ${response.status}`);
}
const answerSdp = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
await waitForPeerConnected(pc, 10000);
await waitForOutboundAudioFlow(pc, 6000);
await readyMicWhip?.(target);
}, },
[openMicEnabled, pttActive, readyMicWhip, startMicWhip], [readyMicWhip, startMicWhip, stopMicCapture, serverUrl],
); );
const teardownClipPipeline = useCallback(async () => { const teardownClipPipeline = useCallback(async () => {
@@ -410,8 +402,6 @@ export default function VipAudioUploadCard({
}, [ensureClipPipeline, roverId]); }, [ensureClipPipeline, roverId]);
useEffect(() => { useEffect(() => {
let cancelled = false;
async function syncLiveMic() { async function syncLiveMic() {
if (clipMode) { if (clipMode) {
await stopMicCapture(activeRoverRef.current || roverId); await stopMicCapture(activeRoverRef.current || roverId);
@@ -425,34 +415,31 @@ export default function VipAudioUploadCard({
if (micActiveRef.current && activeRoverRef.current === roverId) return; if (micActiveRef.current && activeRoverRef.current === roverId) return;
if (!openMicEnabled && !pttActive) return; if (!openMicEnabled && !pttActive) return;
const attempt = {};
micAttemptRef.current = attempt;
try { try {
setMicState('starting'); setMicState('starting');
setMessage(''); setMessage('');
micActiveRef.current = true; micActiveRef.current = true;
activeRoverRef.current = roverId; activeRoverRef.current = roverId;
await startWhipMic(roverId); await startWhipMic(roverId, attempt);
if (!cancelled) {
setMicState('live');
}
} catch (err) { } catch (err) {
if (!cancelled) { if (micAttemptRef.current === attempt) {
await stopMicCapture(roverId);
if (micAttemptRef.current) return;
setMicState('error'); setMicState('error');
setMessage(err?.message || 'Failed to start mic forwarding.'); setMessage(err?.message || 'Failed to start mic forwarding.');
} }
await stopMicCapture(roverId);
} }
} }
syncLiveMic(); syncLiveMic();
return () => {
cancelled = true;
};
}, [clipMode, openMicEnabled, pttActive, roverId, startWhipMic, stopMicCapture]); }, [clipMode, openMicEnabled, pttActive, roverId, startWhipMic, stopMicCapture]);
useEffect(() => { useLayoutEffect(() => {
micEnabledRef.current = Boolean(!clipMode && (openMicEnabled || pttActive));
const track = audioTrackRef.current; const track = audioTrackRef.current;
if (!track) return; if (track) track.enabled = micEnabledRef.current;
track.enabled = Boolean(!clipMode && (openMicEnabled || pttActive));
}, [clipMode, openMicEnabled, pttActive]); }, [clipMode, openMicEnabled, pttActive]);
useEffect(() => { useEffect(() => {
@@ -10,14 +10,3 @@ export function bytesToBase64(bytes) {
} }
return btoa(binary); return btoa(binary);
} }
function encodeBase64(value) {
if (typeof btoa === 'function') return btoa(value);
return '';
}
export function buildAuthHeader(token) {
if (!token) return {};
const encoded = encodeBase64(`${token}:${token}`);
return encoded ? { Authorization: `Basic ${encoded}` } : {};
}
@@ -3,8 +3,3 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
export const TARGET_SAMPLE_RATE = 16000; export const TARGET_SAMPLE_RATE = 16000;
export const RTC_CONFIG = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
};
@@ -1,91 +1,6 @@
// whip Transport // whip Transport
// Purpose: Defines the whip Transport module and the local helpers/components used in this file. // Purpose: Defines the whip Transport module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
export function waitForIceGatheringComplete(pc, timeoutMs = 1500) {
return new Promise((resolve) => {
if (!pc || pc.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(() => {
pc.removeEventListener('icegatheringstatechange', onChange);
resolve();
}, timeoutMs);
function onChange() {
if (pc.iceGatheringState === 'complete') {
clearTimeout(timer);
pc.removeEventListener('icegatheringstatechange', onChange);
resolve();
}
}
pc.addEventListener('icegatheringstatechange', onChange);
});
}
function isPeerTransportReady(pc) {
if (!pc) return false;
const conn = pc.connectionState;
const ice = pc.iceConnectionState;
if (conn === 'connected') return true;
if (ice === 'connected' || ice === 'completed') return true;
return false;
}
export function waitForPeerConnected(pc, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
if (!pc) {
reject(new Error('Peer connection missing'));
return;
}
if (isPeerTransportReady(pc)) {
resolve();
return;
}
const timer = setTimeout(() => {
cleanup();
reject(new Error('Peer connection timeout'));
}, timeoutMs);
const onState = () => {
if (isPeerTransportReady(pc)) {
cleanup();
resolve();
} else if (
pc.connectionState === 'failed' ||
pc.connectionState === 'closed' ||
pc.iceConnectionState === 'failed'
) {
cleanup();
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
}
};
function cleanup() {
clearTimeout(timer);
pc.removeEventListener('connectionstatechange', onState);
pc.removeEventListener('iceconnectionstatechange', onState);
}
pc.addEventListener('connectionstatechange', onState);
pc.addEventListener('iceconnectionstatechange', onState);
});
}
export async function configureSenderForLowLatency(sender) {
if (!sender?.getParameters || !sender?.setParameters) return;
const params = sender.getParameters() || {};
const first = (params.encodings && params.encodings[0]) || {};
params.encodings = [
{
...first,
maxBitrate: 64000,
dtx: 'disabled',
},
];
try {
await sender.setParameters(params);
} catch {
// Browser support varies; keep defaults if rejected.
}
}
export function waitForOutboundAudioFlow(pc, timeoutMs = 6000) { export function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!pc) { if (!pc) {
-20
View File
@@ -16,7 +16,6 @@ const INITIAL_STATE = {
overseerMemory: null, overseerMemory: null,
alerts: [], alerts: [],
latestReplay: null, latestReplay: null,
replayStatus: null,
duplicateIdentityBlock: null, duplicateIdentityBlock: null,
roverRemovalNotice: null, roverRemovalNotice: null,
}; };
@@ -199,23 +198,6 @@ export function SessionProvider({ children }) {
], ],
})); }));
} }
function handleReplayStatus(payload = {}) {
if (!payload?.status) return;
setState((prev) => ({
...prev,
/*
Replay creation is serialized by the server's cooldown and workflow,
so the browser needs one current progress value rather than a map of
jobs. The server remains authoritative for job identity and execution;
this scalar exists only to present accepted/building/uploading/ready/
failed progress in the web UI.
*/
replayStatus: {
...payload,
receivedAt: Date.now(),
},
}));
}
function handleReplayReady(payload = {}) { function handleReplayReady(payload = {}) {
if (!payload?.jobId || !payload?.url) return; if (!payload?.jobId || !payload?.url) return;
setState((prev) => ({ setState((prev) => ({
@@ -316,7 +298,6 @@ export function SessionProvider({ children }) {
socket.on('overseer:state', handleOverseerState); socket.on('overseer:state', handleOverseerState);
socket.on('overseer:memory', handleOverseerMemory); socket.on('overseer:memory', handleOverseerMemory);
socket.on('alert:new', handleAlertNew); socket.on('alert:new', handleAlertNew);
socket.on('replay:status', handleReplayStatus);
socket.on('replay:ready', handleReplayReady); socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed); socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity); socket.on('session:duplicateIdentity', handleDuplicateIdentity);
@@ -331,7 +312,6 @@ export function SessionProvider({ children }) {
socket.off('overseer:state', handleOverseerState); socket.off('overseer:state', handleOverseerState);
socket.off('overseer:memory', handleOverseerMemory); socket.off('overseer:memory', handleOverseerMemory);
socket.off('alert:new', handleAlertNew); socket.off('alert:new', handleAlertNew);
socket.off('replay:status', handleReplayStatus);
socket.off('replay:ready', handleReplayReady); socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed); socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity); socket.off('session:duplicateIdentity', handleDuplicateIdentity);
@@ -1,8 +1,14 @@
// Server Display App Root // Server Display App Root
// Purpose: Defines the route root for the physical-room display page. // Purpose: Defines the route root for the physical-room display page.
// Scope: Keeps this passive spectator-style surface separate from the main driver UI. // Scope: Keeps this passive spectator-style surface separate from the main driver UI.
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
import ServerDisplayContent from './ServerDisplayContent.jsx'; import ServerDisplayContent from './ServerDisplayContent.jsx';
export default function ServerDisplayAppRoot() { export default function ServerDisplayAppRoot() {
return <ServerDisplayContent />; return (
<>
<RoomAudioPlayer />
<ServerDisplayContent />
</>
);
} }
+2 -7
View File
@@ -1,5 +1,6 @@
// Main Application Shell // Main Application Shell
// Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route. // Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route.
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import AlertFeed from '../components/AlertFeed/index.jsx'; import AlertFeed from '../components/AlertFeed/index.jsx';
import { import {
@@ -27,7 +28,6 @@ import useLayoutMode from '../hooks/useLayoutMode.js';
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx'; import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
import { LayoutProvider } from '../layouts/LayoutContext.jsx'; import { LayoutProvider } from '../layouts/LayoutContext.jsx';
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx'; import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */ /* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
function DriverPageRoot({ oldDesktop = false }) { function DriverPageRoot({ oldDesktop = false }) {
@@ -90,12 +90,7 @@ function DriverPageContent({ layout, oldDesktop }) {
); );
return ( return (
<ControlSystemProvider> <ControlSystemProvider>
{/* <RoomAudioPlayer />
Keep document-exit protection inside the control provider so it follows
the same assigned-rover identity as every driving command. Mounting it
only on the driver page leaves spectator and utility routes unchanged.
*/}
<UndockedPageExitGuard />
<KeyboardInputManager /> <KeyboardInputManager />
<GamepadInputManager /> <GamepadInputManager />
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}> <main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
+8
View File
@@ -0,0 +1,8 @@
import { useCallback } from 'react';
import { useSessionSelector } from '../context/SessionContext.jsx';
export default function useServerUrl() {
const publicUrl = useSessionSelector((state) => state.session?.interInstances?.profile?.publicUrl);
// The UI may be served by Vite while session data comes from the actual server.
return useCallback((url) => new URL(url, publicUrl || window.location.origin).href, [publicUrl]);
}
@@ -55,12 +55,12 @@ function CommunityColumn() {
<div className="h-96 min-h-56"> <div className="h-96 min-h-56">
<ChatPanel fillHeight /> <ChatPanel fillHeight />
</div> </div>
<OverseerPreferencePanel />
<ReplaySourcesPanel panelId="newdrive-replay-sources" /> <ReplaySourcesPanel panelId="newdrive-replay-sources" />
<RoverQueuesPanel title="Rovers" /> <RoverQueuesPanel title="Rovers" />
<LinkButtonsPanel fillHeight={false} /> <LinkButtonsPanel fillHeight={false} />
<PtzQueueCard layout="desktop" /> <PtzQueueCard layout="desktop" />
<RawUserPilePanel compact hideNicknameForm /> <RawUserPilePanel compact hideNicknameForm />
<OverseerPreferencePanel />
</div> </div>
); );
} }
+3
View File
@@ -40,5 +40,8 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal
export function openExternalRover(instance, roverId, { includeSettings = true } = {}) { export function openExternalRover(instance, roverId, { includeSettings = true } = {}) {
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings }); const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
if (!targetUrl) return; if (!targetUrl) return;
// These queue actions are buttons, so let the page guard veto navigation
// before beforeunload would pause painting behind the native browser dialog.
if (!window.dispatchEvent(new Event('page:navigate', { cancelable: true }))) return;
window.location.href = targetUrl; window.location.href = targetUrl;
} }
+1
View File
@@ -42,6 +42,7 @@ async function buildSocketIdentity() {
audioAdjustments: currentSettings?.audioAdjustments || {}, audioAdjustments: currentSettings?.audioAdjustments || {},
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled), overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
identitySurface: getIdentitySurface(), identitySurface: getIdentitySurface(),
role: window.location.pathname === '/mic' ? 'spectator' : 'user',
operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover', operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover',
}; };
} }
+44 -29
View File
@@ -2,7 +2,7 @@
// Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app. // Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app.
import { lazy, StrictMode, Suspense } from 'react' import { lazy, StrictMode, Suspense } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { createBrowserRouter, Route, RouterProvider, Routes } from 'react-router-dom'
import './index.css' import './index.css'
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated // Theme artwork is a separate style concern from global component utilities. Loading its dedicated
// entrypoint here keeps every route consistent without returning theme definitions to index.css. // entrypoint here keeps every route consistent without returning theme definitions to index.css.
@@ -20,8 +20,11 @@ import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx' import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx' import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx' import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import MicApp from './mic/MicApp.jsx'
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx' import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
import PageExitGuard from './components/PageExitGuard/index.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx' import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
// Reporting and administration carry substantial route-specific libraries. // Reporting and administration carry substantial route-specific libraries.
// Loading each only on its own route keeps charting and JSON Schema tooling out // Loading each only on its own route keeps charting and JSON Schema tooling out
@@ -39,7 +42,44 @@ function lazyRoute(element, label) {
) )
} }
// A data router provides one blocker for links, programmatic navigation, and history.
const router = createBrowserRouter([{
path: '*',
element: (
<>
<AnalyticsReporter />
<OperatingModeRouteSync />
<PageExitGuard />
<Routes>
<Route path="/" element={<DriverPageRoot />} />
{/* The retired desktop composition remains available for direct comparison
without duplicating the driver shell or changing canonical mobile layouts. */}
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />
<Route path="/mic" element={<MicApp />} />
<Route path="/scanner" element={<ScannerApp />} />
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
{/*
PTZ is a separate route so the driver layout and its replay
panel are not mounted behind the camera controller. This
also makes orientation changes a PTZ layout concern instead
of a local overlay-open state owned by the driver page.
*/}
<Route path="/ptz" element={<PtzAppRoot />} />
<Route
path="/reports"
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
/>
</Routes>
</>
),
}])
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<AppErrorBoundary>
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
@@ -52,37 +92,12 @@ createRoot(document.getElementById('root')).render(
<SettingsProvider> <SettingsProvider>
<ChatProvider> <ChatProvider>
<DeterrenceChaos /> <DeterrenceChaos />
<BrowserRouter> <RouterProvider router={router} />
<AnalyticsReporter />
<OperatingModeRouteSync />
<Routes>
<Route path="/" element={<DriverPageRoot />} />
{/* The retired desktop composition remains available for direct comparison
without duplicating the driver shell or changing canonical mobile layouts. */}
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />
<Route path="/scanner" element={<ScannerApp />} />
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
{/*
PTZ is a separate route so the driver layout and its replay
panel are not mounted behind the camera controller. This
also makes orientation changes a PTZ layout concern instead
of a local overlay-open state owned by the driver page.
*/}
<Route path="/ptz" element={<PtzAppRoot />} />
<Route
path="/reports"
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
/>
</Routes>
</BrowserRouter>
</ChatProvider> </ChatProvider>
</SettingsProvider> </SettingsProvider>
</TelemetryProvider> </TelemetryProvider>
</SessionProvider> </SessionProvider>
</SocketProvider> </SocketProvider>
</StrictMode>, </StrictMode>
</AppErrorBoundary>,
) )
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import { RESTART_DELAY_MS } from '../lib/whepPlayback.js';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager, useControlSelector } from '../controls/index.js';
import ControlHint from '../components/ControlHint/index.jsx';
import MediaMTXWebRTCPublisher from '../../.cache/mediamtx/publisher.js';
import useServerUrl from '../hooks/useServerUrl.js';
export default function MicApp() {
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<MicPage />
</ControlSystemProvider>
);
}
function MicPage() {
const socket = useSocket();
const serverUrl = useServerUrl();
const connected = useSessionSelector((state) => state.connected);
const local = useSessionSelector((state) => state.session?.isLocalNetwork);
const enabled = useSessionSelector((state) => state.session?.features?.roomAudio);
const [status, setStatus] = useState('Connecting…');
const [pttMode, setPttMode] = useState(false);
const available = Boolean(connected && local && enabled);
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
const [pointerHeld, setPointerHeld] = useState(false);
const [buttonKeyHeld, setButtonKeyHeld] = useState(false);
const held = available && pttMode && (pttActive || pointerHeld || buttonKeyHeld);
const transmitting = available && (!pttMode || held);
const mediaRef = useRef(null);
const transmittingRef = useRef(transmitting);
useLayoutEffect(() => {
transmittingRef.current = transmitting;
mediaRef.current?.getAudioTracks().forEach((track) => { track.enabled = transmitting; });
}, [transmitting]);
useEffect(() => {
// Pointer capture can outlive focus; release the page button when leaving the page.
function releaseButton() {
setPointerHeld(false);
setButtonKeyHeld(false);
}
function visibilityChange() {
if (document.hidden) releaseButton();
}
window.addEventListener('blur', releaseButton);
document.addEventListener('visibilitychange', visibilityChange);
return () => {
window.removeEventListener('blur', releaseButton);
document.removeEventListener('visibilitychange', visibilityChange);
};
}, []);
useUserIdentitySync();
useEffect(() => {
if (!connected || !local || !enabled) return undefined;
let disposed = false;
let attempt;
let media;
let retryTimer;
const ack = (event, payload = {}) => new Promise((resolve, reject) => {
socket.timeout(5000).emit(event, payload, (error, result = {}) => {
if (error || result.error) reject(error || new Error(result.error));
else resolve(result);
});
});
const isCurrent = (current) => !disposed && Boolean(current) && attempt === current;
function closeAttempt() {
const current = attempt;
attempt = null;
if (!current) return;
const publisher = current.publisher;
publisher?.close();
// Upstream close stops the peer and retry timer but leaves HTTP session cleanup to us.
if (publisher?.sessionUrl) fetch(publisher.sessionUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
}
function retry(current, message) {
if (!isCurrent(current)) return;
closeAttempt();
clearTimeout(retryTimer);
setStatus(message);
retryTimer = setTimeout(start, RESTART_DELAY_MS);
}
async function start() {
if (disposed) return;
// Invalidate the previous attempt before closing it. Late async results
// and peer events must never change the new attempt's state or timers.
closeAttempt();
const current = {};
attempt = current;
setStatus('Starting microphone…');
try {
await ack('session:setRole', { role: 'spectator' });
if (!isCurrent(current)) return;
if (!media || media.getAudioTracks().every((track) => track.readyState === 'ended')) {
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Microphone access requires HTTPS or localhost');
const captured = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!isCurrent(current)) { captured.getTracks().forEach((track) => track.stop()); return; }
media = captured;
mediaRef.current = media;
// New capture and reconnect attempts must respect the current PTT state immediately.
media.getAudioTracks().forEach((track) => { track.enabled = transmittingRef.current; });
media.getAudioTracks().forEach((track) => { track.onended = () => retry(attempt, 'Microphone disconnected. Retrying…'); });
}
const source = await ack('roomAudio:start');
if (!isCurrent(current)) return;
setStatus('Connecting audio…');
current.publisher = new MediaMTXWebRTCPublisher({
url: serverUrl(source.url),
user: source.token,
pass: source.token,
stream: media,
audioCodec: 'opus',
audioBitrate: 64,
audioVoice: true,
onConnected: () => {
if (isCurrent(current)) setStatus('Microphone live');
},
// The publisher owns transport retries; restarting it here would create competing loops.
onError: (message) => {
if (isCurrent(current)) setStatus(message);
},
});
} catch (error) { retry(current, `${error.message}. Retrying…`); }
}
start();
return () => {
disposed = true;
clearTimeout(retryTimer);
closeAttempt();
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
mediaRef.current = null;
if (socket.connected) socket.emit('roomAudio:stop', {});
};
}, [socket, connected, local, enabled, serverUrl]);
const message = !connected ? 'Connecting…' : local === false ? 'This page is available to local visitors only.' : !enabled ? 'Room audio is disabled.' : status;
return (
<main className="min-h-screen bg-neutral-950 p-4 text-slate-200">
<div className="panel mx-auto max-w-md p-4">
<h1 className="text-lg font-semibold">Room microphone</h1>
<p className="mt-2 text-sm" role="status">{message}</p>
<div className="mt-4 grid grid-cols-2 gap-2" role="group" aria-label="Microphone mode">
{[false, true].map((ptt) => (
<button key={String(ptt)} type="button" aria-pressed={pttMode === ptt}
className={`button-dark w-full ${pttMode === ptt ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
onClick={() => {
setPointerHeld(false);
setButtonKeyHeld(false);
setPttMode(ptt);
}}>
{ptt ? 'Push to talk' : 'Open mic'}
</button>
))}
</div>
{pttMode && (
<>
<button type="button" disabled={!available} aria-pressed={held}
className={`button-dark mt-4 min-h-40 w-full touch-none select-none text-2xl font-semibold ${held ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
onPointerDown={(event) => {
if (event.button !== 0 || !event.isPrimary) return;
event.currentTarget.setPointerCapture(event.pointerId);
setPointerHeld(true);
}}
onPointerUp={() => setPointerHeld(false)}
onPointerCancel={() => setPointerHeld(false)}
onLostPointerCapture={() => setPointerHeld(false)}
onContextMenu={(event) => event.preventDefault()}
onKeyDown={(event) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
event.preventDefault();
setButtonKeyHeld(true);
}}
onKeyUp={(event) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
event.preventDefault();
setButtonKeyHeld(false);
}}
onBlur={() => setButtonKeyHeld(false)}>
{held ? 'Talking…' : 'Hold to talk'}
</button>
<p className="mt-2 text-center text-sm text-slate-400">Hold the button or <ControlHint actionId="micPtt" /> to talk.</p>
</>
)}
</div>
</main>
);
}
@@ -1,6 +1,7 @@
// Mini Summary App Root // Mini Summary App Root
// Purpose: Defines the Mini Summary App Root module and the local helpers/components used in this file. // Purpose: Defines the Mini Summary App Root module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
import { useEffect } from 'react'; import { useEffect } from 'react';
import AlertFeed from '../../components/AlertFeed/index.jsx'; import AlertFeed from '../../components/AlertFeed/index.jsx';
import MiniSummaryContent from './MiniSummaryContent.jsx'; import MiniSummaryContent from './MiniSummaryContent.jsx';
@@ -19,6 +20,7 @@ export default function MiniSummaryAppRoot() {
return ( return (
<> <>
<RoomAudioPlayer />
<MiniSummaryContent /> <MiniSummaryContent />
<AlertFeed scale={3} opacity={1}/> <AlertFeed scale={3} opacity={1}/>
</> </>
+2
View File
@@ -2,6 +2,7 @@
// Purpose: Mounts the PTZ controller as a real page with the same shared input // Purpose: Mounts the PTZ controller as a real page with the same shared input
// and identity systems used by the driver page. // and identity systems used by the driver page.
// Scope: Mounts input/identity providers; the page owns session entry and layouts. // Scope: Mounts input/identity providers; the page owns session entry and layouts.
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
import AlertFeed from '../components/AlertFeed/index.jsx'; import AlertFeed from '../components/AlertFeed/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx'; import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { LayoutProvider } from '../layouts/LayoutContext.jsx'; import { LayoutProvider } from '../layouts/LayoutContext.jsx';
@@ -25,6 +26,7 @@ function PtzRouteContent() {
return ( return (
<ControlSystemProvider> <ControlSystemProvider>
<RoomAudioPlayer />
<KeyboardInputManager /> <KeyboardInputManager />
<GamepadInputManager /> <GamepadInputManager />
<LayoutProvider layout={layout}> <LayoutProvider layout={layout}>
+1
View File
@@ -176,6 +176,7 @@ export const AUDIO_SETTINGS_DEFAULTS = {
masterVolume: 1, masterVolume: 1,
alertVolume: 0.5, alertVolume: 0.5,
roverVolume: 1, roverVolume: 1,
roomVolume: 1,
mainBrushDuckEnabled: true, mainBrushDuckEnabled: true,
mainBrushDuckAmount: 0.75, mainBrushDuckAmount: 0.75,
}; };
@@ -1,8 +1,14 @@
// Spectator App Root // Spectator App Root
// Purpose: Defines the Spectator App Root module and the local helpers/components used in this file. // Purpose: Defines the Spectator App Root module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
import SpectatorContent from './SpectatorContent.jsx'; import SpectatorContent from './SpectatorContent.jsx';
export default function SpectatorAppRoot() { export default function SpectatorAppRoot() {
return <SpectatorContent />; return (
<>
<RoomAudioPlayer />
<SpectatorContent />
</>
);
} }