mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
Compare commits
20
Commits
2b48fee00f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b57ac1725 | ||
|
|
00cd125fda | ||
|
|
684acdd2b6 | ||
|
|
f138cd7b96 | ||
|
|
6454a9b460 | ||
|
|
1976377235 | ||
|
|
52c171c256 | ||
|
|
42bf67d207 | ||
|
|
d65fd99de4 | ||
|
|
e3d4d9af9b | ||
|
|
ab730ee8a3 | ||
|
|
8fe4f2f98e | ||
|
|
89f3a1cb65 | ||
|
|
79d36cae93 | ||
|
|
1dcd7e5d7d | ||
|
|
b4bc99ab27 | ||
|
|
c94e1a0508 | ||
|
|
abcb0871b2 | ||
|
|
1ffe680895 | ||
|
|
ed26b90384 |
@@ -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
|
||||
|
||||
@@ -42,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -187,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),
|
||||
@@ -263,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);
|
||||
}
|
||||
|
||||
@@ -287,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 }) => {
|
||||
@@ -365,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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "node scripts/prepareMediaMtx.js",
|
||||
"dev": "VITE_ROVERD_URL=https://rovertest.otter.land vite",
|
||||
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||
"prebuild": "node scripts/prepareMediaMtx.js",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,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 }) {
|
||||
@@ -92,7 +91,6 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<RoomAudioPlayer />
|
||||
<UndockedPageExitGuard />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+39
-29
@@ -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.
|
||||
@@ -22,6 +22,7 @@ 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'
|
||||
|
||||
@@ -41,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>
|
||||
@@ -55,34 +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="/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>
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
</ChatProvider>
|
||||
</SettingsProvider>
|
||||
</TelemetryProvider>
|
||||
|
||||
Reference in New Issue
Block a user