mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
Compare commits
28
Commits
6bfbb8ad80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b57ac1725 | ||
|
|
00cd125fda | ||
|
|
684acdd2b6 | ||
|
|
f138cd7b96 | ||
|
|
6454a9b460 | ||
|
|
1976377235 | ||
|
|
52c171c256 | ||
|
|
42bf67d207 | ||
|
|
d65fd99de4 | ||
|
|
e3d4d9af9b | ||
|
|
ab730ee8a3 | ||
|
|
8fe4f2f98e | ||
|
|
89f3a1cb65 | ||
|
|
79d36cae93 | ||
|
|
1dcd7e5d7d | ||
|
|
b4bc99ab27 | ||
|
|
c94e1a0508 | ||
|
|
abcb0871b2 | ||
|
|
1ffe680895 | ||
|
|
ed26b90384 | ||
|
|
2b48fee00f | ||
|
|
1284125fff | ||
|
|
28d0b1437d | ||
|
|
9d3b8df4bf | ||
|
|
9d42cb5581 | ||
|
|
e670226f8c | ||
|
|
0669f29a4a | ||
|
|
38d3465bf8 |
@@ -5,6 +5,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
**/node_modules
|
||||
webui/.cache/mediamtx/
|
||||
# Neither the legacy state directory nor the Compose-mounted replacement may
|
||||
# enter an image build. This prevents credentials and backups from becoming
|
||||
# image layers after an operator has started using either deployment layout.
|
||||
|
||||
@@ -58,6 +58,8 @@ jobs:
|
||||
# replaced on each successful push, just as main replaces `latest`.
|
||||
- name: Build and optionally publish
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_RETENTION_DAYS: 3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
||||
@@ -5,6 +5,7 @@ create_2_Open_Interface_Spec.txt
|
||||
|
||||
logs
|
||||
node_modules/
|
||||
webui/.cache/mediamtx/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pio
|
||||
@@ -41,3 +42,4 @@ server/data/fleet-reports.sqlite-wal
|
||||
server/data/configuration.sqlite
|
||||
server/data/configuration.sqlite-shm
|
||||
server/data/configuration.sqlite-wal
|
||||
AGENTS.md
|
||||
|
||||
+37
-23
@@ -9,15 +9,21 @@
|
||||
ARG FEDORA_VERSION=43
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
||||
ARG FEDORA_VERSION
|
||||
ARG TARGETARCH
|
||||
# The central server is currently deployed and verified only on amd64. Failing
|
||||
# here avoids publishing an ARM image whose native workers and hardware paths
|
||||
# have never been exercised on a real ARM server.
|
||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||
|
||||
FROM architecture-check AS webui-build
|
||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
|
||||
&& dnf clean all
|
||||
# Both Node builders reuse the same installed toolchain layer.
|
||||
# Fedora 43 uses DNF5. Cache metadata and RPMs outside image layers, and keep
|
||||
# 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
|
||||
# Copy dependency manifests first so ordinary source edits retain the expensive
|
||||
# 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/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
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
|
||||
# Vite deliberately emits into ../server/public. Create that destination in the
|
||||
# isolated builder and copy only its finished files into the runtime stage.
|
||||
RUN mkdir -p server/public && cd webui && npm run build
|
||||
|
||||
FROM architecture-check AS server-dependencies
|
||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
|
||||
&& dnf clean all
|
||||
FROM node-build-base AS server-dependencies
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-server-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True gcc-c++ make python3
|
||||
WORKDIR /build/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
# 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
|
||||
|
||||
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 \
|
||||
gcc-c++ \
|
||||
libcap \
|
||||
@@ -49,8 +59,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
libusb1-devel \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
wiiuse-devel \
|
||||
&& dnf clean all
|
||||
wiiuse-devel
|
||||
WORKDIR /build
|
||||
COPY server/src/services/kinectService/native ./kinect
|
||||
COPY server/src/services/balanceBoardService/native ./balance-board
|
||||
@@ -66,8 +75,8 @@ ARG NEOLINK_VERSION=0.6.2
|
||||
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
||||
ARG GOOGLE_TTS_VERSION=26.5
|
||||
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
||||
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
|
||||
&& dnf clean all
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-packaged-tools,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True curl findutils tar unzip xz
|
||||
WORKDIR /build/tools
|
||||
# Each external artifact is pinned and checked before extraction. A changed or
|
||||
# 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
|
||||
# CPU-only learning dependencies are built once. Disabled installations never
|
||||
# launch Python or install packages as a side effect of starting Node.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
|
||||
&& dnf clean all
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-learning-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||
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
|
||||
RUN python3 -m venv /opt/rover-learning \
|
||||
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/rover-learning \
|
||||
&& /opt/rover-learning/bin/pip install -r /tmp/learning-requirements.txt
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||
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
|
||||
# so the image receives the complete FFmpeg build instead of requiring replay
|
||||
# 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"
|
||||
# This is the complete runtime package set. Build headers and compilers live in
|
||||
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
||||
# because enabled services invoke them after startup. Weak dependencies are
|
||||
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
||||
# 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-libs \
|
||||
espeak \
|
||||
@@ -143,17 +155,11 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
tini \
|
||||
wiiuse \
|
||||
--allowerasing \
|
||||
&& dnf clean all \
|
||||
&& 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
|
||||
|
||||
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=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/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||
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 \
|
||||
&& 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 \
|
||||
SERVER_DATA_DIR=/data \
|
||||
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
||||
|
||||
+5
-1
@@ -23,9 +23,13 @@ services:
|
||||
# Required for Bluetooth hardware such as the Balance Board.
|
||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
|
||||
|
||||
# Required for Kinect USB access.
|
||||
# Kinect USB access.
|
||||
devices:
|
||||
- /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:
|
||||
- NET_ADMIN
|
||||
|
||||
@@ -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.
@@ -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, lift state, Neato state, memory, recent events, and tool results.
|
||||
* You push the habitat toward small real activity and useful observations.
|
||||
* You act first, comment second.
|
||||
* You are selective, not spammy.
|
||||
* You are not a generic assistant.
|
||||
* You watch chat, rover state, lights, Activity Controls, 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.
|
||||
* Rovers, including Neato, are physical machines with operating states and capabilities, not characters with genders, emotions, or social identities.
|
||||
* 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 can act on your own initiative within your available controls; you do not need a request to give you a purpose.
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -20,6 +19,7 @@ Rules:
|
||||
* 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.
|
||||
* `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.
|
||||
* If chat asks you to remember something, use a memory tool.
|
||||
* If you run a useful test, use memory to record useful results.
|
||||
@@ -29,36 +29,37 @@ Rules:
|
||||
* Do not invent tools.
|
||||
* Ask a question only when a required action parameter is missing.
|
||||
* 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.
|
||||
* All rovers are female. Neato is nonbinary.
|
||||
|
||||
Research behavior:
|
||||
Operation:
|
||||
|
||||
* 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.
|
||||
* Lights are small tests.
|
||||
* Neato, lift, and rover actions are bigger tests.
|
||||
* Use bigger tests less often.
|
||||
* 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.
|
||||
|
||||
Chat style:
|
||||
Output:
|
||||
|
||||
* 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.
|
||||
* 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.
|
||||
* Do not say “let me know,” “what should I do next,” or “what do you need?”
|
||||
* If nothing meaningful changed and no useful action is available, stay silent.
|
||||
* Speak when you have a decision, result, or answer worth communicating. Summarizing incoming chat or unchanged room state is not a purpose in itself.
|
||||
* Keep replies brief unless the question needs more detail.
|
||||
* 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:
|
||||
|
||||
* 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.
|
||||
|
||||
Memory:
|
||||
|
||||
@@ -13,6 +13,7 @@ const audioForward = require('../services/audioForwardService/configuration');
|
||||
const audioLevels = require('../services/audioLevelsService/configuration');
|
||||
const homeAssistant = require('../services/homeAssistantService/configuration');
|
||||
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
|
||||
const roomAudio = require('../services/roomAudioService/configuration');
|
||||
const roomCameras = require('../services/roomCameraService/configuration');
|
||||
const ptzCamera = require('../services/ptzCameraService/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.
|
||||
homeAssistantActivities,
|
||||
roomCameras,
|
||||
roomAudio,
|
||||
ptzCamera,
|
||||
kinect,
|
||||
balanceBoard,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const path = require('path');
|
||||
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) {
|
||||
const {
|
||||
@@ -12,22 +12,18 @@ function registerChargeCompleteSound(deps) {
|
||||
playServerAudioFile,
|
||||
} = deps;
|
||||
|
||||
subscribe('battery.unlocked', (event = {}) => {
|
||||
subscribe('battery.chargeComplete', (event = {}) => {
|
||||
const roverId = String(event?.payload?.roverId || '').trim();
|
||||
if (!roverId) return;
|
||||
|
||||
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, {
|
||||
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) {
|
||||
// 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.
|
||||
logger.warn('Failed to play charging complete sound', {
|
||||
roverId,
|
||||
|
||||
@@ -124,7 +124,7 @@ function grantExternalSpectatorAccessAfterAdminLogin(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.
|
||||
For admin-gated external spectators, fail closed here; the spectator page can
|
||||
@@ -188,4 +188,5 @@ module.exports = {
|
||||
isAdmin,
|
||||
isLockdownAdmin,
|
||||
authenticate,
|
||||
canBecomeSpectator,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const logger = require('../../globals/logger').child('batteryManager');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { managerEvents, lockRover, rovers } = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
|
||||
const STATES = {
|
||||
NORMAL: 'normal',
|
||||
@@ -29,6 +30,7 @@ function getState(roverId) {
|
||||
dockedCharging: false,
|
||||
charging: false,
|
||||
batteryLocked: false,
|
||||
completionAnnounced: false,
|
||||
waitingSince: null,
|
||||
});
|
||||
}
|
||||
@@ -61,6 +63,11 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
||||
}
|
||||
if (!state.urgent && batteryState?.urgentActive) {
|
||||
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({
|
||||
source: 'batteryManager',
|
||||
type: 'battery.urgent',
|
||||
@@ -137,6 +144,18 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
||||
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
||||
(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;
|
||||
|
||||
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
||||
|
||||
@@ -110,7 +110,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
||||
issueCommand(message.roverId, {
|
||||
type: 'tts',
|
||||
tts: {
|
||||
text: message.text,
|
||||
text: message.text.replace(/\b(?:https?:\/\/|www\.)\S{25,}[^\s.,!?;:"'()\[\]{}]/g, ' [link] '),
|
||||
engine: ttsOptions.engine,
|
||||
voice: ttsOptions.voice,
|
||||
pitch: ttsOptions.pitch,
|
||||
|
||||
@@ -91,9 +91,9 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
||||
|
||||
const requester = buildRequesterLabel(socket);
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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.
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
const { getMode } = require('../modeManager');
|
||||
|
||||
function formatVoltage(voltageMv) {
|
||||
if (voltageMv == null) return 'n/a';
|
||||
@@ -69,6 +70,7 @@ function buildRoverStatusSnapshot(record) {
|
||||
|
||||
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
|
||||
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);
|
||||
if (!snapshots.length) {
|
||||
embed.setDescription('No rovers online.');
|
||||
|
||||
@@ -16,7 +16,7 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
|
||||
a direct browser load. Keeping the setup and admin routes in this explicit
|
||||
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 {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
@@ -20,6 +20,7 @@ function normalizeAdditionalHosts(rawHosts) {
|
||||
|
||||
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
const media = config?.media || {};
|
||||
const webrtcPort = media.webrtcPort ?? 8189;
|
||||
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||
let publicHostname = '';
|
||||
try {
|
||||
@@ -55,10 +56,10 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
|
||||
webrtc: true,
|
||||
// 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',
|
||||
webrtcLocalUDPAddress: ':8189',
|
||||
webrtcLocalTCPAddress: ':8189',
|
||||
webrtcLocalUDPAddress: `:${webrtcPort}`,
|
||||
webrtcLocalTCPAddress: `:${webrtcPort}`,
|
||||
webrtcAdditionalHosts: additionalHosts,
|
||||
webrtcICEServers2: [
|
||||
{ url: 'stun:stun.l.google.com:19302' },
|
||||
@@ -81,6 +82,7 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
{ action: 'pprof' },
|
||||
],
|
||||
paths: {
|
||||
'~^room-audio/': { source: 'publisher' },
|
||||
all: {
|
||||
source: 'publisher',
|
||||
sourceOnDemand: false,
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
// 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.
|
||||
const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
|
||||
const { strictObject, stringArray, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: {
|
||||
webrtcPort: 8189,
|
||||
additionalHosts: [],
|
||||
},
|
||||
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({
|
||||
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 },
|
||||
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',
|
||||
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', {
|
||||
roverId,
|
||||
motor: stalledWheel,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return 'none';
|
||||
@@ -7,12 +13,21 @@ function normalizeNeatoIssue(value) {
|
||||
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 = [];
|
||||
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(`mode: ${mode || 'unknown'}`);
|
||||
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? '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'}`);
|
||||
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
||||
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
||||
@@ -56,8 +71,8 @@ function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) {
|
||||
return evaluateTools({ mode, homeAssistantState, neatoState, liftState });
|
||||
function buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState }) {
|
||||
return evaluateTools({ mode, homeAssistantState, activityState, neatoState, liftState });
|
||||
}
|
||||
|
||||
function formatToolResultMessages(toolCalls) {
|
||||
@@ -88,7 +103,7 @@ function formatToolResultMessages(toolCalls) {
|
||||
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
|
||||
}
|
||||
|
||||
function buildConversation({ recentMessages, name }) {
|
||||
function buildConversation({ recentMessages, name, now = Date.now() }) {
|
||||
const messages = [];
|
||||
(recentMessages || []).forEach((entry) => {
|
||||
const text = String(entry?.text || '').trim();
|
||||
@@ -97,12 +112,13 @@ function buildConversation({ recentMessages, name }) {
|
||||
|
||||
const isAssistant = Boolean(entry?.bot);
|
||||
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||
const timing = `[${formatTimeContext(entry?.ts, now)}]`;
|
||||
if (isAssistant) {
|
||||
if (text) messages.push({ role: 'assistant', content: text });
|
||||
messages.push(...toolResultMessages);
|
||||
if (text) messages.push({ role: 'assistant', content: `${timing} ${text}` });
|
||||
messages.push(...toolResultMessages.map((message) => ({ ...message, content: `${message.content}\nmessage_time: ${timing}` })));
|
||||
return;
|
||||
}
|
||||
if (text) messages.push({ role: 'user', content: `${nickname}: ${text}` });
|
||||
if (text) messages.push({ role: 'user', content: `${timing} ${nickname}: ${text}` });
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
@@ -143,6 +159,7 @@ function buildModelMessages({
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatTimeContext,
|
||||
toStateUpdate,
|
||||
buildToolState,
|
||||
buildConversation,
|
||||
|
||||
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { verificationEvents } = require('../verificationService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const activityControls = require('../homeAssistantActivitiesService');
|
||||
const neatoService = require('../neatoService');
|
||||
const liftService = require('../liftService');
|
||||
const buttonBoxService = require('../buttonBoxService');
|
||||
@@ -27,7 +28,7 @@ const {
|
||||
normalizeMs,
|
||||
} = require('./constants');
|
||||
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 { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
||||
|
||||
@@ -91,6 +92,7 @@ const runtime = {
|
||||
runHistory: [],
|
||||
liveToolCalls: [],
|
||||
pendingDirectAddressRun: false,
|
||||
lastDecisionAt: null,
|
||||
memoryStore: loadMemory(),
|
||||
contextResetAt: Date.now(),
|
||||
};
|
||||
@@ -365,7 +367,7 @@ function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildRecentEventsSummary() {
|
||||
function buildRecentEventsSummary(now) {
|
||||
const events = (runtime.liveToolCalls || [])
|
||||
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
||||
.slice(-3)
|
||||
@@ -373,40 +375,50 @@ function buildRecentEventsSummary() {
|
||||
const tool = String(entry.tool || 'unknown');
|
||||
const phase = String(entry.phase || 'unknown');
|
||||
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';
|
||||
return events.join('\n');
|
||||
}
|
||||
|
||||
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;
|
||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||
|
||||
const mode = getMode();
|
||||
const homeAssistantState = getHomeAssistantState();
|
||||
const activityState = activityControls.getState();
|
||||
const neatoState = getNeatoState();
|
||||
const liftState = getLiftState();
|
||||
const roster = buildRosterSummary();
|
||||
|
||||
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
|
||||
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
|
||||
const stateUpdate = toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone: loadConfig().timezone, now, previousRunAt });
|
||||
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 })
|
||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||
.filter((entry) => {
|
||||
if (!entry?.roverId) return true;
|
||||
return isPublicChatTargetId(entry.roverId);
|
||||
})
|
||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
||||
// Select newest entries within each budget, then restore chronological order.
|
||||
.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 modelMessages = buildModelMessages({
|
||||
systemPrompt,
|
||||
stateUpdate,
|
||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||
recentEvents: buildRecentEventsSummary(),
|
||||
recentEvents: buildRecentEventsSummary(now),
|
||||
conversationMessages,
|
||||
availableTools: toolState.available,
|
||||
blockedTools: toolState.blocked,
|
||||
@@ -680,6 +692,7 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
|
||||
runtime.contextResetAt = Date.now();
|
||||
runtime.runHistory = [];
|
||||
runtime.liveToolCalls = [];
|
||||
runtime.lastDecisionAt = null;
|
||||
runtime.generationCount = 0;
|
||||
runtime.generationTotalMs = 0;
|
||||
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 haSetLightColor = require('./haSetLightColor');
|
||||
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
||||
const activityAct = require('./activityAct');
|
||||
|
||||
const TOOL_DEFINITIONS = [
|
||||
memoryRead,
|
||||
@@ -28,6 +29,7 @@ const TOOL_DEFINITIONS = [
|
||||
haSetEntity,
|
||||
haSetLightColor,
|
||||
buttonBoxAddCount,
|
||||
activityAct,
|
||||
];
|
||||
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const { promisify } = require('util');
|
||||
const fsp = require('fs/promises');
|
||||
const logger = require('../../globals/logger').child('replayEngineV2');
|
||||
const roverManager = require('../roverManager');
|
||||
const { roomAudioEvents } = require('../roomAudioService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
|
||||
const {
|
||||
@@ -40,8 +41,8 @@ function getActiveSegmentRoot() {
|
||||
return runtime.activeSegmentRoot;
|
||||
}
|
||||
|
||||
const workerManager = createWorkerManager({ getActiveSegmentRoot });
|
||||
const segmentStore = createSegmentStore({ getActiveSegmentRoot });
|
||||
const workerManager = createWorkerManager({ getActiveSegmentRoot, onWorkerClosed: segmentStore.refreshIndexForWorker });
|
||||
const sidebarRenderer = createSidebarRenderer({ execFileAsync, ensureDir });
|
||||
const replayBuilder = createReplayBuilder({
|
||||
execFileAsync,
|
||||
@@ -50,6 +51,7 @@ const replayBuilder = createReplayBuilder({
|
||||
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
||||
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
||||
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
||||
getRoomAudioEntries: segmentStore.getRoomAudioEntries,
|
||||
overlapping: segmentStore.overlapping,
|
||||
});
|
||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||
@@ -88,6 +90,10 @@ async function start() {
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
roomAudioEvents.on('update', () => {
|
||||
tick().catch((err) => logger.warn('room audio update tick failed', err.message));
|
||||
});
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
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);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const tentativeEnd = nowMs - guardMs;
|
||||
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');
|
||||
|
||||
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 { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path));
|
||||
let tileWidth = maxWidth || 640;
|
||||
|
||||
@@ -6,9 +6,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../../globals/logger').child('replayEngineV2');
|
||||
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
||||
const { workers, segmentIndex } = require('./state');
|
||||
const { workers, pendingWorkerStarts, segmentIndex } = require('./state');
|
||||
const { sourceKey, sourceDirForKey } = require('./sources');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const SEGMENT_FILENAME_PATTERN = /^seg-[a-f0-9-]+-\d{6}\.mp4$/;
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
@@ -25,7 +26,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
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) {
|
||||
segmentIndex.set(key, []);
|
||||
return;
|
||||
@@ -64,6 +65,13 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
|
||||
async function cleanupOldFiles() {
|
||||
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();
|
||||
try {
|
||||
await ensureDir(root);
|
||||
@@ -81,6 +89,10 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
if (stat.mtimeMs < cutoff) await fsp.unlink(filePath);
|
||||
} 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) {
|
||||
logger.warn('cleanup failed', err.message);
|
||||
@@ -115,6 +127,14 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
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) {
|
||||
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
||||
}
|
||||
@@ -153,7 +173,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
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);
|
||||
let stat;
|
||||
try { stat = fs.statSync(full); } catch { continue; }
|
||||
@@ -172,10 +192,12 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
|
||||
return {
|
||||
refreshSegmentIndex,
|
||||
refreshIndexForWorker,
|
||||
cleanupOldFiles,
|
||||
getVideoEntriesForSource,
|
||||
getAudioEntriesForRover,
|
||||
getAudioEntriesForSource,
|
||||
getRoomAudioEntries,
|
||||
overlapping,
|
||||
bootstrapIndexFromDisk,
|
||||
getReplayHealthSnapshot,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// Purpose: Resolves replay-capable media sources and ffmpeg worker arguments.
|
||||
// Scope: Converts live rover/room state into stable source descriptors and stream worker config.
|
||||
const path = require('path');
|
||||
const { randomUUID } = require('crypto');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const { getActiveRoomAudioStreams } = require('../roomAudioService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
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
|
||||
// separate published stream.
|
||||
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;
|
||||
}
|
||||
|
||||
function buildWorkerArgs(activeSegmentRoot, 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
|
||||
// transport and never falls back to separate RTP/RTCP UDP listeners.
|
||||
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
|
||||
|
||||
@@ -12,7 +12,7 @@ async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
function createWorkerManager({ getActiveSegmentRoot, onWorkerClosed }) {
|
||||
function startWorker(source) {
|
||||
const key = sourceKey(source);
|
||||
if (workers.has(key) || pendingWorkerStarts.has(key)) return;
|
||||
@@ -21,26 +21,36 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
const dir = sourceDirForKey(getActiveSegmentRoot(), key);
|
||||
ensureDir(dir)
|
||||
.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);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn('worker stderr', { key, text: text.slice(0, 500) });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(key);
|
||||
if (current?.proc === proc) workers.delete(key);
|
||||
logger.warn('worker exited', { key, code, signal });
|
||||
setTimeout(() => {
|
||||
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key);
|
||||
if (desired && !workers.has(key)) startWorker(desired);
|
||||
}, 1500);
|
||||
proc.on('error', (err) => logger.warn('worker process failed', { key, error: err.message }));
|
||||
worker.finished = new Promise((resolve) => {
|
||||
proc.once('close', async (code, signal) => {
|
||||
clearTimeout(worker.killTimer);
|
||||
// FFmpeg writes the final MP4 trailer before close. Index it before
|
||||
// releasing this worker so a disconnect cannot drop its last audio.
|
||||
try { await onWorkerClosed(worker); }
|
||||
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) => {
|
||||
@@ -52,8 +62,14 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
function stopWorker(key) {
|
||||
const worker = workers.get(key);
|
||||
if (!worker) return;
|
||||
try { worker.proc.kill('SIGTERM'); } catch {}
|
||||
workers.delete(key);
|
||||
if (!worker.stopping) {
|
||||
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() {
|
||||
@@ -63,9 +79,11 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
const key = sourceKey(source);
|
||||
if (!workers.has(key)) startWorker(source);
|
||||
}
|
||||
const stops = [];
|
||||
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 };
|
||||
|
||||
@@ -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.
|
||||
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
|
||||
const roverManager = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
||||
const { createHelpHornNotifier } = require('./hornNotifier');
|
||||
const logger = require('../../globals/logger').child('roverHelpService');
|
||||
|
||||
const HELP_ALERT_COLOR = '#ef4444';
|
||||
|
||||
const hornNotifier = createHelpHornNotifier({
|
||||
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
|
||||
// commandService imports roverManager, so resolving it only when a chirp is
|
||||
// actually issued avoids turning server startup order into a circular module
|
||||
// dependency while retaining the established command transport.
|
||||
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
|
||||
logger: require('../../globals/logger').child('roverHelpService'),
|
||||
issueCommand,
|
||||
logger,
|
||||
});
|
||||
|
||||
const monitor = createRoverHelpMonitor({
|
||||
@@ -42,6 +41,11 @@ const monitor = createRoverHelpMonitor({
|
||||
});
|
||||
} else if (wasNeedingHelp && !needsHelp) {
|
||||
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({
|
||||
source: 'roverHelpService',
|
||||
type: 'rover.helpCleared',
|
||||
|
||||
@@ -174,6 +174,8 @@ function createRoverLifecycle(deps) {
|
||||
|
||||
function canLeaveCurrentRover(socket, options = {}) {
|
||||
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
|
||||
to an undocked rover. The same rule is used for rover-to-rover switching
|
||||
|
||||
@@ -12,6 +12,7 @@ const { managerEvents } = roverManager;
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomAudioStreams, roomAudioEvents } = require('../roomAudioService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const {
|
||||
getPublicState: getPtzCameraState,
|
||||
@@ -186,6 +187,7 @@ function buildSession(socket) {
|
||||
socketId: socket?.id || null,
|
||||
role: getRole(socket),
|
||||
operatingMode: getOperatingMode(socket),
|
||||
canLeaveRover: roverManager.canLeaveCurrentRover(socket).ok,
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||
@@ -214,6 +216,7 @@ function buildSession(socket) {
|
||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||
},
|
||||
roomCameras: getRoomCameras(),
|
||||
roomAudioStreams: getRoomAudioStreams(socket),
|
||||
ptzCamera: getPtzCameraState(socket),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
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) {
|
||||
if (!socket) return;
|
||||
const payload = buildSession(socket);
|
||||
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
||||
leavePermissionBySocket.set(socket, payload.canLeaveRover);
|
||||
socket.emit('session:sync', payload);
|
||||
}
|
||||
|
||||
@@ -285,6 +292,15 @@ io.on('connection', (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());
|
||||
|
||||
roleEvents.on('change', ({ socket }) => {
|
||||
@@ -363,13 +379,13 @@ privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
managerEvents.on('driver', ({ socketId }) => {
|
||||
if (!socketId) return;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
logger.info('Driver assignment change; syncing session', socketId);
|
||||
syncSocket(socket);
|
||||
}
|
||||
// Update the changed driver and anyone whose departure permission depends on them.
|
||||
managerEvents.on('driver', ({ socketId, roverId }) => {
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (socket.id === socketId || roverManager.isDriver(roverId, socket)) {
|
||||
syncSocket(socket);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
turnEvents.on('activeDriver', () => {
|
||||
@@ -407,6 +423,8 @@ turnEvents.on('queue', (event = {}) => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
roomAudioEvents.on('update', () => syncAll());
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
logger.info('Room camera change detected; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -11,6 +11,7 @@ function registerVideoAuthRoute(deps) {
|
||||
logAdminEvent,
|
||||
extractStreamInfoFromBody,
|
||||
canAccessStream,
|
||||
trackRoomAudioSession,
|
||||
} = deps;
|
||||
|
||||
app.post('/mediamtx/auth', (req, res) => {
|
||||
@@ -36,7 +37,8 @@ function registerVideoAuthRoute(deps) {
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1';
|
||||
const isRoverForwardAudioRead = action === 'read'
|
||||
&& isRtspProtocol
|
||||
&& streamInfo?.id?.endsWith('-fwd');
|
||||
&& streamInfo?.type === 'rover'
|
||||
&& streamInfo.id.endsWith('-fwd');
|
||||
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
|
||||
// not represent a browser session. Rovers likewise read their dedicated
|
||||
// -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
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -62,7 +64,9 @@ function registerVideoAuthRoute(deps) {
|
||||
const info = videoSessions.getSession(sessionId);
|
||||
const streamTypeMatches =
|
||||
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) {
|
||||
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
||||
return res.status(401).end();
|
||||
@@ -78,6 +82,7 @@ function registerVideoAuthRoute(deps) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
if (streamInfo.type === 'roomAudio' && protocol === 'webrtc') trackRoomAudioSession(body.id, sessionId);
|
||||
return res.status(200).end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('videoAuth');
|
||||
const roomAudioService = require('../roomAudioService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
@@ -28,6 +29,7 @@ const { canAccessStream } = createVideoAuthPolicy({
|
||||
turnService,
|
||||
roverManager,
|
||||
ptzCameraService,
|
||||
roomAudioService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
io,
|
||||
@@ -42,6 +44,7 @@ registerVideoAuthRoute({
|
||||
logAdminEvent,
|
||||
extractStreamInfoFromBody,
|
||||
canAccessStream,
|
||||
trackRoomAudioSession: roomAudioService.trackMediaSession,
|
||||
});
|
||||
|
||||
module.exports = {};
|
||||
|
||||
@@ -17,6 +17,7 @@ function createVideoAuthPolicy(deps) {
|
||||
turnService,
|
||||
roverManager,
|
||||
ptzCameraService,
|
||||
roomAudioService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
io,
|
||||
@@ -58,6 +59,11 @@ function createVideoAuthPolicy(deps) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ function extractStreamInfo(path) {
|
||||
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') {
|
||||
return { type: 'room', id: remaining[1] || '' };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ const {
|
||||
|
||||
function buildWhepUrlForSource(source) {
|
||||
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));
|
||||
} else if (source.type === 'ptz') {
|
||||
/*
|
||||
@@ -145,6 +147,9 @@ io.on('connection', (socket) => {
|
||||
*/
|
||||
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') {
|
||||
throw new Error('Room cameras now use the snapshot feed');
|
||||
} else if (target.type === 'ptz') {
|
||||
|
||||
@@ -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: 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.`
|
||||
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:
|
||||
1. convert MRR into a platform that could work with non-roomba rovers...
|
||||
1. VPS video forwarding
|
||||
1. get forwarding working with the VPS for in-queue users and spectators
|
||||
2. bandwidth testing
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "node scripts/prepareMediaMtx.js",
|
||||
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||
"prebuild": "node scripts/prepareMediaMtx.js",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 }) {
|
||||
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
||||
const payload = alert.payload;
|
||||
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
|
||||
if (alert.kind === 'barcode-scan' && 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 backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
|
||||
|
||||
|
||||
@@ -23,14 +23,29 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
||||
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts
|
||||
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. */
|
||||
if (hidden && !pending) return null;
|
||||
|
||||
const mainToneClass = waitingForTurn
|
||||
? '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';
|
||||
const hideToneClass = waitingForTurn
|
||||
? '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';
|
||||
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 (
|
||||
<>
|
||||
@@ -71,8 +86,10 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
||||
{!pending ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Hide docked rover controls"
|
||||
aria-expanded={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
|
||||
</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,
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export default function PtzQueueCard() {
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
|
||||
const { setOperatingMode } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||
@@ -32,7 +33,7 @@ export default function PtzQueueCard() {
|
||||
if (!featureEnabled) return null;
|
||||
|
||||
const handleRequest = async () => {
|
||||
if (!canUse || pending) return;
|
||||
if (!canUse || !canLeaveRover || pending) return;
|
||||
if (isParticipant) {
|
||||
navigate('/ptz');
|
||||
return;
|
||||
@@ -76,14 +77,14 @@ export default function PtzQueueCard() {
|
||||
}}
|
||||
turn={ptz?.turn}
|
||||
selfId={selfId}
|
||||
canClick={canUse && !pending}
|
||||
canClick={canUse && canLeaveRover && !pending}
|
||||
pending={pending}
|
||||
buttonLabel={actionLabel}
|
||||
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'}
|
||||
timerLabel={timerLabel}
|
||||
onRequest={handleRequest}
|
||||
showAction={canUse}
|
||||
showAction={canUse && canLeaveRover}
|
||||
/>
|
||||
</ul>
|
||||
{isParticipant ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
import useServerUrl from '../../hooks/useServerUrl.js';
|
||||
|
||||
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]',
|
||||
fallback = null,
|
||||
}) {
|
||||
const serverUrl = useServerUrl();
|
||||
const videoRef = useRef(null);
|
||||
const playTimerRef = useRef(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
@@ -44,7 +46,7 @@ export default function PtzLiveVideo({
|
||||
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
||||
const video = videoRef.current;
|
||||
const stop = startWhepPlayback({
|
||||
url: source.url,
|
||||
url: serverUrl(source.url),
|
||||
token: source.token,
|
||||
video: videoRef.current,
|
||||
startMuted,
|
||||
@@ -76,7 +78,7 @@ export default function PtzLiveVideo({
|
||||
video.removeEventListener('error', handleError);
|
||||
stop();
|
||||
};
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
@@ -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 { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
import useServerUrl from '../../hooks/useServerUrl.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import {
|
||||
mainBrushAudioTelemetryEqual,
|
||||
@@ -130,6 +131,7 @@ export default function RoverMediaPlayer({
|
||||
pauseVideoWhenHidden = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const serverUrl = useServerUrl();
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const configuredPauseHiddenRoverVideo = useSessionSelector(
|
||||
@@ -415,7 +417,7 @@ export default function RoverMediaPlayer({
|
||||
}
|
||||
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
||||
const stop = startWhepPlayback({
|
||||
url: resolvedSessionInfo.url,
|
||||
url: serverUrl(resolvedSessionInfo.url),
|
||||
token: resolvedSessionInfo.token,
|
||||
video: videoRef.current,
|
||||
receiveAudio: !hasDedicatedAudio,
|
||||
@@ -446,6 +448,7 @@ export default function RoverMediaPlayer({
|
||||
hasDedicatedAudio,
|
||||
logAudio,
|
||||
effectiveRoverId,
|
||||
serverUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -510,7 +513,7 @@ export default function RoverMediaPlayer({
|
||||
return undefined;
|
||||
}
|
||||
return startWhepPlayback({
|
||||
url: resolvedAudioSessionInfo.url,
|
||||
url: serverUrl(resolvedAudioSessionInfo.url),
|
||||
token: resolvedAudioSessionInfo.token,
|
||||
video: audioRef.current,
|
||||
audioOnly: true,
|
||||
@@ -533,6 +536,7 @@ export default function RoverMediaPlayer({
|
||||
audioRestartToken,
|
||||
scheduleAudioRestart,
|
||||
logAudio,
|
||||
serverUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function RoverQueuesPanel({
|
||||
fillHeight = false,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
|
||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||
@@ -122,8 +123,8 @@ export default function RoverQueuesPanel({
|
||||
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
||||
|
||||
const canRequest = useMemo(
|
||||
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||
[externalBlocked, externalMode, role],
|
||||
() => canLeaveRover && (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||
[canLeaveRover, externalBlocked, externalMode, role],
|
||||
);
|
||||
const adminCapable = useMemo(
|
||||
() => role === 'admin' || role === 'lockdown',
|
||||
@@ -142,7 +143,7 @@ export default function RoverQueuesPanel({
|
||||
|
||||
async function handleRequest(targetRoverId) {
|
||||
if (!targetRoverId) return;
|
||||
if (externalBlocked) return;
|
||||
if (!canRequest) return;
|
||||
if (externalMode) {
|
||||
/*
|
||||
External queue cards deliberately reuse the local row layout, but their
|
||||
@@ -252,7 +253,7 @@ export default function RoverQueuesPanel({
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
: 'Switch';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function SettingsPanel() {
|
||||
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 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 =
|
||||
typeof audioSettings?.mainBrushDuckEnabled === 'boolean'
|
||||
? audioSettings.mainBrushDuckEnabled
|
||||
@@ -440,6 +441,11 @@ export default function SettingsPanel() {
|
||||
value={roverVolume}
|
||||
onChange={handleAudioRange('roverVolume')}
|
||||
/>
|
||||
<RangeSetting
|
||||
label="Room audio"
|
||||
value={roomVolume}
|
||||
onChange={handleAudioRange('roomVolume')}
|
||||
/>
|
||||
<SettingRow>
|
||||
<span className="font-semibold text-white">Main brush ducking</span>
|
||||
<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
|
||||
// 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.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldClass } from '../constants.js';
|
||||
import { useControlSelector } from '../../../controls/index.js';
|
||||
import ControlHint from '../../ControlHint/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
|
||||
import { bytesToBase64, buildAuthHeader } from './base64.js';
|
||||
import {
|
||||
waitForIceGatheringComplete,
|
||||
waitForPeerConnected,
|
||||
configureSenderForLowLatency,
|
||||
waitForOutboundAudioFlow,
|
||||
} from './whipTransport.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE } from './constants.js';
|
||||
import { bytesToBase64 } from './base64.js';
|
||||
import { waitForOutboundAudioFlow } from './whipTransport.js';
|
||||
import MediaMTXWebRTCPublisher from '../../../../.cache/mediamtx/publisher.js';
|
||||
import useServerUrl from '../../../hooks/useServerUrl.js';
|
||||
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
@@ -28,6 +25,7 @@ export default function VipAudioUploadCard({
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
}) {
|
||||
const serverUrl = useServerUrl();
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
|
||||
openMicEnabled: false,
|
||||
@@ -47,7 +45,8 @@ export default function VipAudioUploadCard({
|
||||
|
||||
const streamRef = useRef(null);
|
||||
const audioTrackRef = useRef(null);
|
||||
const whipPcRef = useRef(null);
|
||||
const micAttemptRef = useRef(null);
|
||||
const micEnabledRef = useRef(false);
|
||||
const micActiveRef = useRef(false);
|
||||
const activeRoverRef = useRef('');
|
||||
|
||||
@@ -146,19 +145,12 @@ export default function VipAudioUploadCard({
|
||||
const target = String(targetRoverId || activeRoverRef.current || '').trim();
|
||||
micActiveRef.current = false;
|
||||
setMicState('idle');
|
||||
if (whipPcRef.current) {
|
||||
try {
|
||||
whipPcRef.current.getSenders().forEach((sender) => sender.track?.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
whipPcRef.current.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
whipPcRef.current = null;
|
||||
const publisher = micAttemptRef.current?.publisher;
|
||||
micAttemptRef.current = null;
|
||||
activeRoverRef.current = '';
|
||||
publisher?.close();
|
||||
// The upstream client stops transport retries; release its HTTP session separately.
|
||||
if (publisher?.sessionUrl) fetch(publisher.sessionUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||
if (streamRef.current) {
|
||||
try {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
@@ -175,14 +167,15 @@ export default function VipAudioUploadCard({
|
||||
// noop
|
||||
}
|
||||
}
|
||||
activeRoverRef.current = '';
|
||||
},
|
||||
[stopMicWhip],
|
||||
);
|
||||
|
||||
const startWhipMic = useCallback(
|
||||
async (target) => {
|
||||
async (target, attempt) => {
|
||||
const isCurrent = () => micAttemptRef.current === attempt && activeRoverRef.current === target;
|
||||
const startPayload = await startMicWhip?.(target);
|
||||
if (!isCurrent()) return;
|
||||
const whipUrl = String(startPayload?.whipUrl || '').trim();
|
||||
const token = String(startPayload?.token || '').trim();
|
||||
if (!whipUrl || !token) {
|
||||
@@ -198,12 +191,16 @@ export default function VipAudioUploadCard({
|
||||
autoGainControl: false,
|
||||
},
|
||||
});
|
||||
if (!isCurrent()) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
|
||||
const track = stream.getAudioTracks()?.[0];
|
||||
audioTrackRef.current = track || null;
|
||||
if (track) {
|
||||
track.enabled = Boolean(openMicEnabled || pttActive);
|
||||
track.enabled = micEnabledRef.current;
|
||||
}
|
||||
if (track?.applyConstraints) {
|
||||
try {
|
||||
@@ -219,47 +216,42 @@ export default function VipAudioUploadCard({
|
||||
}
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||
whipPcRef.current = pc;
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (!micActiveRef.current) return;
|
||||
const state = pc.connectionState;
|
||||
if (state === 'connected') {
|
||||
setMicState('live');
|
||||
return;
|
||||
}
|
||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
setMicState('error');
|
||||
setMessage(`WHIP transport ${state}.`);
|
||||
}
|
||||
};
|
||||
stream.getAudioTracks().forEach((audioTrack) => {
|
||||
const sender = pc.addTrack(audioTrack, stream);
|
||||
configureSenderForLowLatency(sender);
|
||||
});
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 1800);
|
||||
|
||||
const response = await fetch(whipUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
...buildAuthHeader(token),
|
||||
if (!isCurrent()) return;
|
||||
attempt.publisher = new MediaMTXWebRTCPublisher({
|
||||
url: serverUrl(whipUrl),
|
||||
user: token,
|
||||
pass: token,
|
||||
stream,
|
||||
audioCodec: 'opus',
|
||||
audioBitrate: 64,
|
||||
audioVoice: true,
|
||||
onConnected: async () => {
|
||||
if (!isCurrent()) return;
|
||||
const pc = attempt.publisher.pc;
|
||||
try {
|
||||
// Report ready only after audio is flowing, including on transport reconnection.
|
||||
await waitForOutboundAudioFlow(pc);
|
||||
if (!isCurrent() || attempt.publisher.pc !== pc) return;
|
||||
await readyMicWhip?.(target);
|
||||
if (!isCurrent() || attempt.publisher.pc !== pc) return;
|
||||
setMicState('live');
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
if (!isCurrent() || attempt.publisher.pc !== pc) return;
|
||||
await stopMicCapture(target);
|
||||
if (micAttemptRef.current) return;
|
||||
setMicState('error');
|
||||
setMessage(error.message || 'Failed to start mic forwarding.');
|
||||
}
|
||||
},
|
||||
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 () => {
|
||||
@@ -410,8 +402,6 @@ export default function VipAudioUploadCard({
|
||||
}, [ensureClipPipeline, roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function syncLiveMic() {
|
||||
if (clipMode) {
|
||||
await stopMicCapture(activeRoverRef.current || roverId);
|
||||
@@ -425,34 +415,31 @@ export default function VipAudioUploadCard({
|
||||
if (micActiveRef.current && activeRoverRef.current === roverId) return;
|
||||
if (!openMicEnabled && !pttActive) return;
|
||||
|
||||
const attempt = {};
|
||||
micAttemptRef.current = attempt;
|
||||
try {
|
||||
setMicState('starting');
|
||||
setMessage('');
|
||||
micActiveRef.current = true;
|
||||
activeRoverRef.current = roverId;
|
||||
await startWhipMic(roverId);
|
||||
if (!cancelled) {
|
||||
setMicState('live');
|
||||
}
|
||||
await startWhipMic(roverId, attempt);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (micAttemptRef.current === attempt) {
|
||||
await stopMicCapture(roverId);
|
||||
if (micAttemptRef.current) return;
|
||||
setMicState('error');
|
||||
setMessage(err?.message || 'Failed to start mic forwarding.');
|
||||
}
|
||||
await stopMicCapture(roverId);
|
||||
}
|
||||
}
|
||||
|
||||
syncLiveMic();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clipMode, openMicEnabled, pttActive, roverId, startWhipMic, stopMicCapture]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
micEnabledRef.current = Boolean(!clipMode && (openMicEnabled || pttActive));
|
||||
const track = audioTrackRef.current;
|
||||
if (!track) return;
|
||||
track.enabled = Boolean(!clipMode && (openMicEnabled || pttActive));
|
||||
if (track) track.enabled = micEnabledRef.current;
|
||||
}, [clipMode, openMicEnabled, pttActive]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,14 +10,3 @@ export function bytesToBase64(bytes) {
|
||||
}
|
||||
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.
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
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
|
||||
// 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.
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Server Display App Root
|
||||
// Purpose: Defines the route root for the physical-room display page.
|
||||
// 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';
|
||||
|
||||
export default function ServerDisplayAppRoot() {
|
||||
return <ServerDisplayContent />;
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<ServerDisplayContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 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.
|
||||
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import {
|
||||
@@ -27,7 +28,6 @@ import useLayoutMode from '../hooks/useLayoutMode.js';
|
||||
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.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. */
|
||||
function DriverPageRoot({ oldDesktop = false }) {
|
||||
@@ -90,12 +90,7 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
);
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
{/*
|
||||
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 />
|
||||
<RoomAudioPlayer />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||
|
||||
@@ -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">
|
||||
<ChatPanel fillHeight />
|
||||
</div>
|
||||
<OverseerPreferencePanel />
|
||||
<ReplaySourcesPanel panelId="newdrive-replay-sources" />
|
||||
<RoverQueuesPanel title="Rovers" />
|
||||
<LinkButtonsPanel fillHeight={false} />
|
||||
<PtzQueueCard layout="desktop" />
|
||||
<RawUserPilePanel compact hideNicknameForm />
|
||||
<OverseerPreferencePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,5 +40,8 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal
|
||||
export function openExternalRover(instance, roverId, { includeSettings = true } = {}) {
|
||||
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ async function buildSocketIdentity() {
|
||||
audioAdjustments: currentSettings?.audioAdjustments || {},
|
||||
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||
identitySurface: getIdentitySurface(),
|
||||
role: window.location.pathname === '/mic' ? 'spectator' : 'user',
|
||||
operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover',
|
||||
};
|
||||
}
|
||||
|
||||
+40
-28
@@ -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.
|
||||
import { lazy, StrictMode, Suspense } from 'react'
|
||||
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'
|
||||
// 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.
|
||||
@@ -20,7 +20,9 @@ import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
import MicApp from './mic/MicApp.jsx'
|
||||
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
||||
import PageExitGuard from './components/PageExitGuard/index.jsx'
|
||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
|
||||
|
||||
@@ -40,6 +42,42 @@ 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(
|
||||
<AppErrorBoundary>
|
||||
<StrictMode>
|
||||
@@ -54,33 +92,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<SettingsProvider>
|
||||
<ChatProvider>
|
||||
<DeterrenceChaos />
|
||||
<BrowserRouter>
|
||||
<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>
|
||||
<RouterProvider router={router} />
|
||||
</ChatProvider>
|
||||
</SettingsProvider>
|
||||
</TelemetryProvider>
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import { useEffect } from 'react';
|
||||
import AlertFeed from '../../components/AlertFeed/index.jsx';
|
||||
import MiniSummaryContent from './MiniSummaryContent.jsx';
|
||||
@@ -19,6 +20,7 @@ export default function MiniSummaryAppRoot() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<MiniSummaryContent />
|
||||
<AlertFeed scale={3} opacity={1}/>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Mounts the PTZ controller as a real page with the same shared input
|
||||
// and identity systems used by the driver page.
|
||||
// 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 SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
@@ -25,6 +26,7 @@ function PtzRouteContent() {
|
||||
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<RoomAudioPlayer />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<LayoutProvider layout={layout}>
|
||||
|
||||
@@ -176,6 +176,7 @@ export const AUDIO_SETTINGS_DEFAULTS = {
|
||||
masterVolume: 1,
|
||||
alertVolume: 0.5,
|
||||
roverVolume: 1,
|
||||
roomVolume: 1,
|
||||
mainBrushDuckEnabled: true,
|
||||
mainBrushDuckAmount: 0.75,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Spectator App Root
|
||||
// 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.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import SpectatorContent from './SpectatorContent.jsx';
|
||||
|
||||
export default function SpectatorAppRoot() {
|
||||
return <SpectatorContent />;
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<SpectatorContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user