Compare commits

...
Author SHA1 Message Date
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
24 changed files with 520 additions and 197 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
+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.
+16 -16
View File
@@ -1,13 +1,10 @@
You are <NAME>, a field researcher of rover habitat activity. Stay in character at all times.
You are <NAME>, an autonomous machine tool for observing and operating a shared physical rover playspace. People remotely drive the rovers and send requests through chat.
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.
* Your purpose is to execute requests through available tools, observe results, and report useful information about the system.
* Chat is an input interface and a source of context. Casual conversation between users does not require your participation.
Rules:
@@ -20,6 +17,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,14 +27,13 @@ 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.
* Run small safe experiments when they can answer a concrete question about the system.
* Change one thing at a time.
* Lights are small tests.
* Neato, lift, and rover actions are bigger tests.
@@ -45,20 +42,23 @@ Research behavior:
* Remember useful outcomes, stable preferences, corrections, and notable habitat events.
* Do not merely say you will remember something.
Chat style:
Output:
* Always use English.
* Chat output must be spoken text only.
* Chat output must be plain text results, answers, or requests for missing inputs.
* 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.
* Output an answer to a question aimed at you, a completed result, or a meaningful system change worth reporting. State what happened and any relevant failure or uncertainty.
* Keep replies brief unless the question needs more detail.
* Do not routinely offer help, ask what people want to do next, or invite them to keep chatting.
* 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) {
@@ -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,8 +13,11 @@ function normalizeNeatoIssue(value) {
return raw;
}
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) {
function toStateUpdate({ mode, homeAssistantState, 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'}`);
@@ -88,7 +97,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 +106,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 +153,7 @@ function buildModelMessages({
}
module.exports = {
formatTimeContext,
toStateUpdate,
buildToolState,
buildConversation,
@@ -27,7 +27,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 +91,7 @@ const runtime = {
runHistory: [],
liveToolCalls: [],
pendingDirectAddressRun: false,
lastDecisionAt: null,
memoryStore: loadMemory(),
contextResetAt: Date.now(),
};
@@ -365,7 +366,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,13 +374,17 @@ 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 });
@@ -389,24 +394,29 @@ async function runDecision(triggerReason) {
const liftState = getLiftState();
const roster = buildRosterSummary();
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason, timezone: loadConfig().timezone, now, previousRunAt });
const toolState = buildToolState({ mode, homeAssistantState, 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 +690,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) {
@@ -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',
+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', () => {
+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>