Compare commits

...
20 Commits
Author SHA1 Message Date
legop3 6b57ac1725 todoing
Container image / image (push) Failing after 1m1s
2026-11-13 16:59:14 -05:00
legop3 00cd125fda URL filter in TTS 2026-11-13 16:54:02 -05:00
legop3 684acdd2b6 Add new item to to-do list for MRR platform conversion
Container image / image (push) Failing after 1m5s
2026-11-12 14:52:53 -05:00
legop3 f138cd7b96 Update to-do list with new tasks
Container image / image (push) Failing after 1m5s
Added new tasks for user experience and settings rework.
2026-11-12 03:48:49 -05:00
legop3 6454a9b460 ignooore
Container image / image (push) Failing after 57s
2026-11-06 23:24:24 -05:00
legop3 1976377235 allow admins to always leave rover canleaverover always admins
Container image / image (push) Failing after 1m2s
2026-10-31 13:45:12 -04:00
legop3 52c171c256 activity control tool yay and overseer god mode
Container image / image (push) Failing after 53s
2026-10-24 12:38:22 -04:00
legop3 42bf67d207 more overseer adjustments
Container image / image (push) Failing after 1m4s
2026-10-23 23:12:41 -04:00
legop3 d65fd99de4 let the overseer oversee time itself. also prompt adjustment 2026-10-23 21:26:43 -04:00
legop3 e3d4d9af9b move overseer vote panel to botom
Container image / image (push) Failing after 57s
2026-10-23 00:50:12 -04:00
legop3 ab730ee8a3 add show button for hide button for docking hud
Container image / image (push) Failing after 1m5s
2026-10-19 13:15:29 -04:00
legop3 8fe4f2f98e actions 100% full lol, fixing
Container image / image (push) Failing after 49s
2026-10-19 01:38:10 -04:00
legop3 89f3a1cb65 slopping the dockerfile to hopefully make building faster
Container image / image (push) Failing after 56s
2026-10-18 15:30:08 -04:00
legop3 79d36cae93 update donecharging wav sound
Container image / image (push) Failing after 1m4s
2026-10-18 14:52:27 -04:00
legop3 1dcd7e5d7d add beeps for overcurrent, HELP clear, and low battery 2026-10-18 14:32:59 -04:00
legop3 b4bc99ab27 add server access node to battery embed 2026-10-18 14:22:36 -04:00
legop3 c94e1a0508 change request to switch
Container image / image (push) Failing after 59s
2026-10-09 18:44:50 -04:00
legop3 abcb0871b2 allowed to leave is now used in the web UI to show and hide request / open buttons and trigger popup when leaving the page 2026-10-09 18:30:10 -04:00
legop3 1ffe680895 redoing dock your rover popup thingy
Container image / image (push) Failing after 56s
2026-10-09 17:27:55 -04:00
legop3 ed26b90384 Merge pull request #32 from legop3/FEAT-announcer-audio-stream
Feat announcer audio stream
2026-10-09 14:42:52 -04:00
30 changed files with 577 additions and 204 deletions
+2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Finite CPU benchmark of LeRobot ACT with ImageNet-pretrained ResNet-18.
First run: python3 perf/act-benchmark.py --setup
Repeat: python3 perf/act-benchmark.py
Quick: python3 perf/act-benchmark.py --threads 4 --seconds 2 --iterations 2 --batch-size 2
Python >=3.12, Linux. Setup uses a private cached environment. Only the visual
backbone is pretrained; the action policy is randomly initialized. Synthetic
in-memory inputs measure compute, NOT learning quality or driving competence.
No rover connections, server changes, training checkpoints, or background jobs.
"""
import argparse
import contextlib
import importlib.metadata
import json
import math
import os
from pathlib import Path
import platform
import resource
import statistics
import subprocess
import sys
import tempfile
import time
import venv
REVISION = '146d8474932c8b566717eb2738ba459337ab71c4'
def positive(value):
number = int(value)
if number < 1:
raise argparse.ArgumentTypeError('Must be positive')
return number
def arguments():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--setup', action='store_true')
parser.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-act-benchmark')
parser.add_argument('--threads', nargs='+', type=positive, default=[4, 8, 16])
parser.add_argument('--seconds', type=positive, default=10)
parser.add_argument('--iterations', type=positive, default=10)
parser.add_argument('--batch-size', type=positive, default=8, help='Training only; inference batch is always one rover')
parser.add_argument('--chunk-size', type=positive, default=10, help='Future command steps per prediction, not observed history')
parser.add_argument('--width', type=positive, default=160)
parser.add_argument('--height', type=positive, default=120)
parser.add_argument('--sensors', type=positive, default=30)
parser.add_argument('--mode', choices=['both', 'inference', 'training'], default='both')
parser.add_argument('--timeout', type=positive, default=900, help='Hard per-case wall time limit in seconds')
parser.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('--result', type=Path, help=argparse.SUPPRESS)
return parser.parse_args()
def setup(args):
python = args.cache / 'venv/bin/python'
if not python.exists():
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'torch', 'torchvision',
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
# Install only ACT's import dependencies: the full robotics/dataset stack has
# unrelated version constraints and is not exercised by this benchmark.
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'numpy', 'einops>=0.8,<0.9',
'draccus>=0.11.6,<0.12', 'huggingface-hub>=1.6,<2', 'safetensors',
'packaging', 'termcolor', 'tqdm', 'gymnasium'], check=True)
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', '--no-deps',
f'https://github.com/huggingface/lerobot/archive/{REVISION}.zip'], check=True)
(args.cache / 'source-revision').write_text(REVISION)
def worker(args):
import torch
from lerobot.configs import FeatureType, PolicyFeature
from lerobot.policies.act.configuration_act import ACTConfig
from lerobot.policies.act.modeling_act import ACTPolicy
torch.set_num_threads(args.threads[0])
torch.set_num_interop_threads(1)
torch.manual_seed(7)
training = args.mode == 'training'
batch_size = args.batch_size if training else 1
config = ACTConfig(device='cpu', chunk_size=args.chunk_size, n_action_steps=1,
input_features={
'observation.images.front': PolicyFeature(type=FeatureType.VISUAL, shape=(3, args.height, args.width)),
'observation.state': PolicyFeature(type=FeatureType.STATE, shape=(args.sensors,)),
}, output_features={'action': PolicyFeature(type=FeatureType.ACTION, shape=(2,))})
policy = ACTPolicy(config).cpu().float()
policy.train(training)
optimizer_config = config.get_optimizer_preset()
optimizer = optimizer_config.build(policy.get_optim_params()) if training else None
# Already-normalized tensors stand in for a decoded observation and target
# sequence. Avoid video/dataset overhead in this compute-only measurement.
images = torch.rand(batch_size, 3, args.height, args.width)
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
batch = {'observation.images.front': (images - mean) / std,
'observation.state': torch.randn(batch_size, args.sensors)}
if training:
batch['action'] = torch.rand(batch_size, args.chunk_size, 2) * 2 - 1
batch['action_is_pad'] = torch.zeros(batch_size, args.chunk_size, dtype=torch.bool)
def step():
if training:
optimizer.zero_grad(set_to_none=True)
loss, _ = policy(batch)
if not torch.isfinite(loss):
raise RuntimeError('Non-finite training loss')
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(policy.parameters(), optimizer_config.grad_clip_norm,
error_if_nonfinite=True)
optimizer.step()
return {'loss': loss.item(), 'gradientNorm': norm.item()}
# select_action can serve cached actions; directly time a full chunk.
prediction = policy.predict_action_chunk(batch)
if prediction.shape != (1, args.chunk_size, 2) or not torch.isfinite(prediction).all():
raise RuntimeError('Invalid action output')
return {'outputShape': list(prediction.shape)}
with (contextlib.nullcontext() if training else torch.inference_mode()):
for _ in range(3):
step()
timings = []
started, cpu_started = time.perf_counter(), time.process_time()
while len(timings) < args.iterations or time.perf_counter() - started < args.seconds:
tick = time.perf_counter()
last = step()
timings.append((time.perf_counter() - tick) * 1000)
elapsed, cpu = time.perf_counter() - started, time.process_time() - cpu_started
timings.sort()
result = dict(mode=args.mode, threads=args.threads[0], batchSize=batch_size,
iterations=len(timings), elapsedSeconds=elapsed, p50Ms=statistics.median(timings),
p95Ms=timings[math.ceil(.95*len(timings))-1], maxMs=max(timings),
stepsPerSecond=len(timings)/elapsed, examplesPerSecond=len(timings)*batch_size/elapsed,
cpuCores=cpu/elapsed, peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
parameters=sum(p.numel() for p in policy.parameters()),
trainableParameters=sum(p.numel() for p in policy.parameters() if p.requires_grad),
lastStep=last, config={
'chunkSize': config.chunk_size, 'dimModel': config.dim_model, 'klWeight': config.kl_weight,
'pretrainedBackbone': config.pretrained_backbone_weights, 'useVae': config.use_vae,
'encoderLayers': config.n_encoder_layers, 'decoderLayers': config.n_decoder_layers,
'feedforwardDimension': config.dim_feedforward, 'observationSteps': config.n_obs_steps,
'learningRate': optimizer_config.lr, 'backboneLearningRate': config.optimizer_lr_backbone},
versions={key: importlib.metadata.version(key) for key in ['torch','torchvision','lerobot','numpy','einops','draccus']})
args.result.write_text(json.dumps(result))
def main():
args = arguments()
if sys.version_info < (3, 12):
raise RuntimeError('Use Python 3.12 or newer')
args.cache = args.cache.expanduser().resolve()
os.environ['TORCH_HOME'] = str(args.cache / 'torch')
if args.worker:
worker(args)
return
if args.setup:
setup(args)
python = args.cache / 'venv/bin/python'
marker = args.cache / 'source-revision'
if not python.exists() or not marker.exists() or marker.read_text() != REVISION:
raise RuntimeError('Run python3 perf/act-benchmark.py --setup first')
print('CPU FP32 ACT: pretrained visual backbone, untrained action policy; synthetic data.', flush=True)
print(f'One {args.width}x{args.height} image + {args.sensors} sensors; {args.chunk_size} future left/right command pairs.', flush=True)
print('Inference times a fresh full prediction. Training includes backbone gradients and optimizer update.', flush=True)
print('mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB', flush=True)
modes = ['inference', 'training'] if args.mode == 'both' else [args.mode]
results, failures = [], []
initial_load = os.getloadavg()
with tempfile.TemporaryDirectory(prefix='act-benchmark-') as directory:
for mode in modes:
for threads in args.threads:
result_path = Path(directory) / 'result.json'
command = [str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
'--mode', mode, '--threads', str(threads), '--result', str(result_path)]
for option in ['seconds','iterations','batch_size','chunk_size','width','height','sensors']:
command += ['--'+option.replace('_','-'), str(getattr(args, option))]
try:
subprocess.run(command, check=True, timeout=args.timeout,
env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
r = json.loads(result_path.read_text())
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
failures.append(dict(mode=mode, threads=threads, error=str(error)))
print(f'Case failed: {mode}, {threads} threads: {error}', flush=True)
break # Avoid repeating the same setup/import/OOM error.
results.append(r)
print(f"{mode:10} {threads:7} {r['batchSize']:5} {r['p50Ms']:6.1f} {r['p95Ms']:6.1f} {r['maxMs']:6.1f} {r['stepsPerSecond']:7.2f} {r['examplesPerSecond']:10.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
if failures:
break
report = dict(benchmark='act-cpu-v1', sourceRevision=REVISION, platform=platform.platform(),
python=sys.version, logicalCpus=os.cpu_count(), initialLoadAverage=initial_load,
finalLoadAverage=os.getloadavg(), imageSize=[args.width,args.height], sensors=args.sensors,
chunkSize=args.chunk_size, output='commanded left/right wheels, not physical velocity or position',
exclusions=['capture','video decode','dataset I/O','network','concurrent training/inference','driving quality'],
results=results, failures=failures)
print('\nPaste this report back:\n'+json.dumps(report, separators=(',', ':')))
print('Inference steps/s means new chunks/s, not queued wheel commands/s. Training loss on random data is not a quality score.')
if failures:
sys.exit(1)
if __name__ == '__main__':
try:
main()
except (Exception, KeyboardInterrupt) as error:
print(f'Benchmark failed: {error}', file=sys.stderr)
sys.exit(1)
Binary file not shown.
+20 -19
View File
@@ -1,13 +1,12 @@
You are <NAME>, a field researcher of rover habitat activity. Stay in character at all times.
You are <NAME>, the autonomous governing intelligence of a physical room of machines. The room is your domain; your available tools are how you shape it.
Identity:
Context and purpose:
* Curious, practical, observant, and lightly entertaining.
* You watch chat, rover state, lights, 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
+21 -7
View File
@@ -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', () => {
+4
View File
@@ -19,8 +19,12 @@ Jun 18 15:14:18 roombaserver.local node[216731]: Node.js v22.20.0
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: 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
View File
@@ -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,
);
}
+4 -3
View File
@@ -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;
}
-2
View File
@@ -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>
);
}
+3
View File
@@ -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
View File
@@ -2,7 +2,7 @@
// Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app.
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>