mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d4d9af9b | ||
|
|
ab730ee8a3 | ||
|
|
8fe4f2f98e | ||
|
|
89f3a1cb65 | ||
|
|
79d36cae93 | ||
|
|
1dcd7e5d7d | ||
|
|
b4bc99ab27 | ||
|
|
c94e1a0508 | ||
|
|
abcb0871b2 | ||
|
|
1ffe680895 | ||
|
|
ed26b90384 |
@@ -58,6 +58,8 @@ jobs:
|
||||
# replaced on each successful push, just as main replaces `latest`.
|
||||
- name: Build and optionally publish
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_RETENTION_DAYS: 3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
|
||||
+37
-23
@@ -9,15 +9,21 @@
|
||||
ARG FEDORA_VERSION=43
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
||||
ARG FEDORA_VERSION
|
||||
ARG TARGETARCH
|
||||
# The central server is currently deployed and verified only on amd64. Failing
|
||||
# here avoids publishing an ARM image whose native workers and hardware paths
|
||||
# have never been exercised on a real ARM server.
|
||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||
|
||||
FROM architecture-check AS webui-build
|
||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
|
||||
&& dnf clean all
|
||||
# Both Node builders reuse the same installed toolchain layer.
|
||||
# Fedora 43 uses DNF5. Cache metadata and RPMs outside image layers, and keep
|
||||
# downloads after installation so invalidated layers can reuse them.
|
||||
FROM architecture-check AS node-build-base
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-node-build-base,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True nodejs npm
|
||||
|
||||
FROM node-build-base AS webui-build
|
||||
WORKDIR /build
|
||||
# Copy dependency manifests first so ordinary source edits retain the expensive
|
||||
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
|
||||
@@ -25,14 +31,17 @@ WORKDIR /build
|
||||
COPY webui/package.json webui/package-lock.json ./webui/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
cd webui && npm ci
|
||||
# Keep the pinned browser publisher in a reusable layer across UI source edits.
|
||||
COPY webui/scripts/prepareMediaMtx.js ./webui/scripts/prepareMediaMtx.js
|
||||
RUN cd webui && node scripts/prepareMediaMtx.js
|
||||
COPY webui ./webui
|
||||
# Vite deliberately emits into ../server/public. Create that destination in the
|
||||
# isolated builder and copy only its finished files into the runtime stage.
|
||||
RUN mkdir -p server/public && cd webui && npm run build
|
||||
|
||||
FROM architecture-check AS server-dependencies
|
||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
|
||||
&& dnf clean all
|
||||
FROM node-build-base AS server-dependencies
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-server-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True gcc-c++ make python3
|
||||
WORKDIR /build/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
# Native Node modules compile here when a matching prebuild is unavailable;
|
||||
@@ -41,7 +50,8 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --omit=dev
|
||||
|
||||
FROM architecture-check AS native-workers
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-native-workers,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
|
||||
bluez-libs-devel \
|
||||
gcc-c++ \
|
||||
libcap \
|
||||
@@ -49,8 +59,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
libusb1-devel \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
wiiuse-devel \
|
||||
&& dnf clean all
|
||||
wiiuse-devel
|
||||
WORKDIR /build
|
||||
COPY server/src/services/kinectService/native ./kinect
|
||||
COPY server/src/services/balanceBoardService/native ./balance-board
|
||||
@@ -66,8 +75,8 @@ ARG NEOLINK_VERSION=0.6.2
|
||||
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
||||
ARG GOOGLE_TTS_VERSION=26.5
|
||||
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
||||
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
|
||||
&& dnf clean all
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-packaged-tools,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True curl findutils tar unzip xz
|
||||
WORKDIR /build/tools
|
||||
# Each external artifact is pinned and checked before extraction. A changed or
|
||||
# truncated upstream download therefore fails the image build instead of being
|
||||
@@ -100,11 +109,12 @@ RUN curl --fail --location --retry 3 \
|
||||
FROM architecture-check AS learning-dependencies
|
||||
# CPU-only learning dependencies are built once. Disabled installations never
|
||||
# launch Python or install packages as a side effect of starting Node.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
|
||||
&& dnf clean all
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-learning-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True python3 python3-pip
|
||||
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
|
||||
RUN python3 -m venv /opt/rover-learning \
|
||||
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/rover-learning \
|
||||
&& /opt/rover-learning/bin/pip install -r /tmp/learning-requirements.txt
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||
ARG FEDORA_VERSION
|
||||
@@ -114,14 +124,16 @@ RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support on
|
||||
# replay output path. Enable RPM Fusion Free before installing runtime packages
|
||||
# so the image receives the complete FFmpeg build instead of requiring replay
|
||||
# code to work around a deployment-only codec omission.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-runtime,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
|
||||
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
|
||||
# This is the complete runtime package set. Build headers and compilers live in
|
||||
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
||||
# because enabled services invoke them after startup. Weak dependencies are
|
||||
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
||||
# themes, and GPU drivers that a headless server neither starts nor uses.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-runtime,target=/var/cache/libdnf5,sharing=locked \
|
||||
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
|
||||
bluez \
|
||||
bluez-libs \
|
||||
espeak \
|
||||
@@ -143,17 +155,11 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
||||
tini \
|
||||
wiiuse \
|
||||
--allowerasing \
|
||||
&& dnf clean all \
|
||||
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
|
||||
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
|
||||
|
||||
WORKDIR /opt/multirover/server
|
||||
COPY server/index.js server/package.json server/package-lock.json ./
|
||||
COPY server/src ./src
|
||||
COPY server/assets ./assets
|
||||
COPY server/prompts ./prompts
|
||||
COPY --from=server-dependencies /build/server/node_modules ./node_modules
|
||||
COPY --from=webui-build /build/server/public ./public
|
||||
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
||||
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||
COPY --from=packaged-tools /output/ /
|
||||
@@ -176,6 +182,14 @@ RUN setcap cap_net_admin,cap_net_bind_service+ep \
|
||||
&& test -s /tmp/chromegtts-smoke.wav \
|
||||
&& rm /tmp/chromegtts-smoke.wav
|
||||
|
||||
# Frequently edited application files come after dependencies and tool checks,
|
||||
# so server changes do not invalidate the large runtime dependency layers.
|
||||
COPY --from=webui-build /build/server/public ./public
|
||||
COPY server/index.js server/package.json server/package-lock.json ./
|
||||
COPY server/src ./src
|
||||
COPY server/assets ./assets
|
||||
COPY server/prompts ./prompts
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
SERVER_DATA_DIR=/data \
|
||||
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finite CPU benchmark of LeRobot ACT with ImageNet-pretrained ResNet-18.
|
||||
|
||||
First run: python3 perf/act-benchmark.py --setup
|
||||
Repeat: python3 perf/act-benchmark.py
|
||||
Quick: python3 perf/act-benchmark.py --threads 4 --seconds 2 --iterations 2 --batch-size 2
|
||||
|
||||
Python >=3.12, Linux. Setup uses a private cached environment. Only the visual
|
||||
backbone is pretrained; the action policy is randomly initialized. Synthetic
|
||||
in-memory inputs measure compute, NOT learning quality or driving competence.
|
||||
No rover connections, server changes, training checkpoints, or background jobs.
|
||||
"""
|
||||
import argparse
|
||||
import contextlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import resource
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import venv
|
||||
|
||||
REVISION = '146d8474932c8b566717eb2738ba459337ab71c4'
|
||||
|
||||
|
||||
def positive(value):
|
||||
number = int(value)
|
||||
if number < 1:
|
||||
raise argparse.ArgumentTypeError('Must be positive')
|
||||
return number
|
||||
|
||||
|
||||
def arguments():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--setup', action='store_true')
|
||||
parser.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-act-benchmark')
|
||||
parser.add_argument('--threads', nargs='+', type=positive, default=[4, 8, 16])
|
||||
parser.add_argument('--seconds', type=positive, default=10)
|
||||
parser.add_argument('--iterations', type=positive, default=10)
|
||||
parser.add_argument('--batch-size', type=positive, default=8, help='Training only; inference batch is always one rover')
|
||||
parser.add_argument('--chunk-size', type=positive, default=10, help='Future command steps per prediction, not observed history')
|
||||
parser.add_argument('--width', type=positive, default=160)
|
||||
parser.add_argument('--height', type=positive, default=120)
|
||||
parser.add_argument('--sensors', type=positive, default=30)
|
||||
parser.add_argument('--mode', choices=['both', 'inference', 'training'], default='both')
|
||||
parser.add_argument('--timeout', type=positive, default=900, help='Hard per-case wall time limit in seconds')
|
||||
parser.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('--result', type=Path, help=argparse.SUPPRESS)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def setup(args):
|
||||
python = args.cache / 'venv/bin/python'
|
||||
if not python.exists():
|
||||
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
|
||||
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'torch', 'torchvision',
|
||||
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
|
||||
# Install only ACT's import dependencies: the full robotics/dataset stack has
|
||||
# unrelated version constraints and is not exercised by this benchmark.
|
||||
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'numpy', 'einops>=0.8,<0.9',
|
||||
'draccus>=0.11.6,<0.12', 'huggingface-hub>=1.6,<2', 'safetensors',
|
||||
'packaging', 'termcolor', 'tqdm', 'gymnasium'], check=True)
|
||||
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', '--no-deps',
|
||||
f'https://github.com/huggingface/lerobot/archive/{REVISION}.zip'], check=True)
|
||||
(args.cache / 'source-revision').write_text(REVISION)
|
||||
|
||||
|
||||
def worker(args):
|
||||
import torch
|
||||
from lerobot.configs import FeatureType, PolicyFeature
|
||||
from lerobot.policies.act.configuration_act import ACTConfig
|
||||
from lerobot.policies.act.modeling_act import ACTPolicy
|
||||
|
||||
torch.set_num_threads(args.threads[0])
|
||||
torch.set_num_interop_threads(1)
|
||||
torch.manual_seed(7)
|
||||
training = args.mode == 'training'
|
||||
batch_size = args.batch_size if training else 1
|
||||
config = ACTConfig(device='cpu', chunk_size=args.chunk_size, n_action_steps=1,
|
||||
input_features={
|
||||
'observation.images.front': PolicyFeature(type=FeatureType.VISUAL, shape=(3, args.height, args.width)),
|
||||
'observation.state': PolicyFeature(type=FeatureType.STATE, shape=(args.sensors,)),
|
||||
}, output_features={'action': PolicyFeature(type=FeatureType.ACTION, shape=(2,))})
|
||||
policy = ACTPolicy(config).cpu().float()
|
||||
policy.train(training)
|
||||
optimizer_config = config.get_optimizer_preset()
|
||||
optimizer = optimizer_config.build(policy.get_optim_params()) if training else None
|
||||
# Already-normalized tensors stand in for a decoded observation and target
|
||||
# sequence. Avoid video/dataset overhead in this compute-only measurement.
|
||||
images = torch.rand(batch_size, 3, args.height, args.width)
|
||||
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
|
||||
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
|
||||
batch = {'observation.images.front': (images - mean) / std,
|
||||
'observation.state': torch.randn(batch_size, args.sensors)}
|
||||
if training:
|
||||
batch['action'] = torch.rand(batch_size, args.chunk_size, 2) * 2 - 1
|
||||
batch['action_is_pad'] = torch.zeros(batch_size, args.chunk_size, dtype=torch.bool)
|
||||
|
||||
def step():
|
||||
if training:
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
loss, _ = policy(batch)
|
||||
if not torch.isfinite(loss):
|
||||
raise RuntimeError('Non-finite training loss')
|
||||
loss.backward()
|
||||
norm = torch.nn.utils.clip_grad_norm_(policy.parameters(), optimizer_config.grad_clip_norm,
|
||||
error_if_nonfinite=True)
|
||||
optimizer.step()
|
||||
return {'loss': loss.item(), 'gradientNorm': norm.item()}
|
||||
# select_action can serve cached actions; directly time a full chunk.
|
||||
prediction = policy.predict_action_chunk(batch)
|
||||
if prediction.shape != (1, args.chunk_size, 2) or not torch.isfinite(prediction).all():
|
||||
raise RuntimeError('Invalid action output')
|
||||
return {'outputShape': list(prediction.shape)}
|
||||
|
||||
with (contextlib.nullcontext() if training else torch.inference_mode()):
|
||||
for _ in range(3):
|
||||
step()
|
||||
timings = []
|
||||
started, cpu_started = time.perf_counter(), time.process_time()
|
||||
while len(timings) < args.iterations or time.perf_counter() - started < args.seconds:
|
||||
tick = time.perf_counter()
|
||||
last = step()
|
||||
timings.append((time.perf_counter() - tick) * 1000)
|
||||
elapsed, cpu = time.perf_counter() - started, time.process_time() - cpu_started
|
||||
timings.sort()
|
||||
result = dict(mode=args.mode, threads=args.threads[0], batchSize=batch_size,
|
||||
iterations=len(timings), elapsedSeconds=elapsed, p50Ms=statistics.median(timings),
|
||||
p95Ms=timings[math.ceil(.95*len(timings))-1], maxMs=max(timings),
|
||||
stepsPerSecond=len(timings)/elapsed, examplesPerSecond=len(timings)*batch_size/elapsed,
|
||||
cpuCores=cpu/elapsed, peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||
parameters=sum(p.numel() for p in policy.parameters()),
|
||||
trainableParameters=sum(p.numel() for p in policy.parameters() if p.requires_grad),
|
||||
lastStep=last, config={
|
||||
'chunkSize': config.chunk_size, 'dimModel': config.dim_model, 'klWeight': config.kl_weight,
|
||||
'pretrainedBackbone': config.pretrained_backbone_weights, 'useVae': config.use_vae,
|
||||
'encoderLayers': config.n_encoder_layers, 'decoderLayers': config.n_decoder_layers,
|
||||
'feedforwardDimension': config.dim_feedforward, 'observationSteps': config.n_obs_steps,
|
||||
'learningRate': optimizer_config.lr, 'backboneLearningRate': config.optimizer_lr_backbone},
|
||||
versions={key: importlib.metadata.version(key) for key in ['torch','torchvision','lerobot','numpy','einops','draccus']})
|
||||
args.result.write_text(json.dumps(result))
|
||||
|
||||
|
||||
def main():
|
||||
args = arguments()
|
||||
if sys.version_info < (3, 12):
|
||||
raise RuntimeError('Use Python 3.12 or newer')
|
||||
args.cache = args.cache.expanduser().resolve()
|
||||
os.environ['TORCH_HOME'] = str(args.cache / 'torch')
|
||||
if args.worker:
|
||||
worker(args)
|
||||
return
|
||||
if args.setup:
|
||||
setup(args)
|
||||
python = args.cache / 'venv/bin/python'
|
||||
marker = args.cache / 'source-revision'
|
||||
if not python.exists() or not marker.exists() or marker.read_text() != REVISION:
|
||||
raise RuntimeError('Run python3 perf/act-benchmark.py --setup first')
|
||||
print('CPU FP32 ACT: pretrained visual backbone, untrained action policy; synthetic data.', flush=True)
|
||||
print(f'One {args.width}x{args.height} image + {args.sensors} sensors; {args.chunk_size} future left/right command pairs.', flush=True)
|
||||
print('Inference times a fresh full prediction. Training includes backbone gradients and optimizer update.', flush=True)
|
||||
print('mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB', flush=True)
|
||||
modes = ['inference', 'training'] if args.mode == 'both' else [args.mode]
|
||||
results, failures = [], []
|
||||
initial_load = os.getloadavg()
|
||||
with tempfile.TemporaryDirectory(prefix='act-benchmark-') as directory:
|
||||
for mode in modes:
|
||||
for threads in args.threads:
|
||||
result_path = Path(directory) / 'result.json'
|
||||
command = [str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
|
||||
'--mode', mode, '--threads', str(threads), '--result', str(result_path)]
|
||||
for option in ['seconds','iterations','batch_size','chunk_size','width','height','sensors']:
|
||||
command += ['--'+option.replace('_','-'), str(getattr(args, option))]
|
||||
try:
|
||||
subprocess.run(command, check=True, timeout=args.timeout,
|
||||
env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
|
||||
r = json.loads(result_path.read_text())
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||
failures.append(dict(mode=mode, threads=threads, error=str(error)))
|
||||
print(f'Case failed: {mode}, {threads} threads: {error}', flush=True)
|
||||
break # Avoid repeating the same setup/import/OOM error.
|
||||
results.append(r)
|
||||
print(f"{mode:10} {threads:7} {r['batchSize']:5} {r['p50Ms']:6.1f} {r['p95Ms']:6.1f} {r['maxMs']:6.1f} {r['stepsPerSecond']:7.2f} {r['examplesPerSecond']:10.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
|
||||
if failures:
|
||||
break
|
||||
report = dict(benchmark='act-cpu-v1', sourceRevision=REVISION, platform=platform.platform(),
|
||||
python=sys.version, logicalCpus=os.cpu_count(), initialLoadAverage=initial_load,
|
||||
finalLoadAverage=os.getloadavg(), imageSize=[args.width,args.height], sensors=args.sensors,
|
||||
chunkSize=args.chunk_size, output='commanded left/right wheels, not physical velocity or position',
|
||||
exclusions=['capture','video decode','dataset I/O','network','concurrent training/inference','driving quality'],
|
||||
results=results, failures=failures)
|
||||
print('\nPaste this report back:\n'+json.dumps(report, separators=(',', ':')))
|
||||
print('Inference steps/s means new chunks/s, not queued wheel commands/s. Training loss on random data is not a quality score.')
|
||||
if failures:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
print(f'Benchmark failed: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "node scripts/prepareMediaMtx.js",
|
||||
"dev": "VITE_ROVERD_URL=https://rovertest.otter.land vite",
|
||||
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||
"prebuild": "node scripts/prepareMediaMtx.js",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -59,20 +59,6 @@ function BarcodeScanToast({ payload }) {
|
||||
);
|
||||
}
|
||||
|
||||
function UndockedExitWarningToast() {
|
||||
return (
|
||||
<div
|
||||
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
|
||||
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
|
||||
Your rover is still undocked. Please dock it before leaving the page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlertContent({ alert }) {
|
||||
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
||||
const payload = alert.payload;
|
||||
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
|
||||
if (alert.kind === 'barcode-scan' && alert.payload) {
|
||||
return <BarcodeScanToast payload={alert.payload} />;
|
||||
}
|
||||
if (alert.kind === 'undocked-exit-warning') {
|
||||
return <UndockedExitWarningToast />;
|
||||
}
|
||||
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
|
||||
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
|
||||
|
||||
|
||||
@@ -23,14 +23,29 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
||||
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts
|
||||
this component when the rover leaves the base, and its roverId key remounts
|
||||
it for a different assignment, so no persistence or reset effect is needed. */
|
||||
if (hidden && !pending) return null;
|
||||
|
||||
const mainToneClass = waitingForTurn
|
||||
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
|
||||
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75';
|
||||
const hideToneClass = waitingForTurn
|
||||
? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900'
|
||||
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200';
|
||||
const toggleClass = `pointer-events-auto absolute left-1/2 -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${hideToneClass}`;
|
||||
|
||||
// Keep a small reopen control on the video's top edge; pending undocking
|
||||
// still reveals the prompt so progress remains visible after a keybind action.
|
||||
if (hidden && !pending) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show docked rover controls"
|
||||
aria-expanded={false}
|
||||
onClick={() => setHidden(false)}
|
||||
className={`${toggleClass} top-0 z-30`}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,8 +86,10 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
||||
{!pending ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Hide docked rover controls"
|
||||
aria-expanded={true}
|
||||
onClick={() => setHidden(true)}
|
||||
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-hidden focus-visible:ring-4 ${hideToneClass}`}
|
||||
className={`${toggleClass} top-full`}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
export default function PageExitGuard() {
|
||||
const shouldBlock = useSessionSelector((state) => state.session?.canLeaveRover === false);
|
||||
const [warning, setWarning] = useState({ blocked: shouldBlock, visible: false });
|
||||
// Clear an old exit attempt when server permission changes, so undocking
|
||||
// again does not resurrect a warning from the previous trip.
|
||||
if (warning.blocked !== shouldBlock) {
|
||||
setWarning({ blocked: shouldBlock, visible: false });
|
||||
}
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => shouldBlock && (
|
||||
currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldBlock) return undefined;
|
||||
function handleBeforeUnload(event) {
|
||||
// Commit before the native dialog pauses JavaScript. The browser still
|
||||
// decides whether it repaints the document before displaying its dialog.
|
||||
flushSync(() => setWarning({ blocked: true, visible: true }));
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
}
|
||||
function blockNavigation(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setWarning({ blocked: true, visible: true });
|
||||
}
|
||||
function handleLinkClick(event) {
|
||||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
||||
if (!link || link.hasAttribute('download')) return;
|
||||
const target = (link.getAttribute('target') || document.querySelector('base[target]')?.getAttribute('target') || '_self').toLowerCase();
|
||||
if (target !== '_self') return;
|
||||
const destination = new URL(link.href, window.location.href);
|
||||
if (destination.protocol !== 'http:' && destination.protocol !== 'https:') return;
|
||||
// Hash links stay in this document and do not abandon the rover.
|
||||
if (destination.origin === window.location.origin
|
||||
&& destination.pathname === window.location.pathname
|
||||
&& destination.search === window.location.search
|
||||
&& link.getAttribute('href').includes('#')) return;
|
||||
blockNavigation(event);
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
document.addEventListener('click', handleLinkClick, true);
|
||||
window.addEventListener('page:navigate', blockNavigation);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
document.removeEventListener('click', handleLinkClick, true);
|
||||
window.removeEventListener('page:navigate', blockNavigation);
|
||||
};
|
||||
}, [shouldBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
// Docking cancels the attempted navigation; the next attempt can proceed.
|
||||
if (!shouldBlock && blocker.state === 'blocked') blocker.reset();
|
||||
}, [shouldBlock, blocker]);
|
||||
|
||||
const visible = shouldBlock && (warning.visible || blocker.state === 'blocked');
|
||||
if (!visible) return null;
|
||||
|
||||
function dismiss() {
|
||||
setWarning({ blocked: shouldBlock, visible: false });
|
||||
if (blocker.state === 'blocked') blocker.reset();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="page-exit-warning"
|
||||
className="fixed inset-0 z-[2147483647] flex flex-col items-center justify-between gap-4 bg-black p-6 text-center text-white"
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') dismiss();
|
||||
}}
|
||||
>
|
||||
<p id="page-exit-warning" className="text-[clamp(3rem,10vw,10rem)] font-bold leading-tight">
|
||||
Please dock your rover!
|
||||
</p>
|
||||
<button type="button" autoFocus className="button-dark px-8 py-4 text-3xl" onClick={dismiss}>
|
||||
Okay
|
||||
</button>
|
||||
<p aria-hidden="true" className="text-[clamp(3rem,10vw,10rem)] font-bold leading-tight">
|
||||
Please dock your rover!
|
||||
</p>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export default function PtzQueueCard() {
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
|
||||
const { setOperatingMode } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||
@@ -32,7 +33,7 @@ export default function PtzQueueCard() {
|
||||
if (!featureEnabled) return null;
|
||||
|
||||
const handleRequest = async () => {
|
||||
if (!canUse || pending) return;
|
||||
if (!canUse || !canLeaveRover || pending) return;
|
||||
if (isParticipant) {
|
||||
navigate('/ptz');
|
||||
return;
|
||||
@@ -76,14 +77,14 @@ export default function PtzQueueCard() {
|
||||
}}
|
||||
turn={ptz?.turn}
|
||||
selfId={selfId}
|
||||
canClick={canUse && !pending}
|
||||
canClick={canUse && canLeaveRover && !pending}
|
||||
pending={pending}
|
||||
buttonLabel={actionLabel}
|
||||
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
||||
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||
timerLabel={timerLabel}
|
||||
onRequest={handleRequest}
|
||||
showAction={canUse}
|
||||
showAction={canUse && canLeaveRover}
|
||||
/>
|
||||
</ul>
|
||||
{isParticipant ? (
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function RoverQueuesPanel({
|
||||
fillHeight = false,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
|
||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||
@@ -122,8 +123,8 @@ export default function RoverQueuesPanel({
|
||||
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
||||
|
||||
const canRequest = useMemo(
|
||||
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||
[externalBlocked, externalMode, role],
|
||||
() => canLeaveRover && (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||
[canLeaveRover, externalBlocked, externalMode, role],
|
||||
);
|
||||
const adminCapable = useMemo(
|
||||
() => role === 'admin' || role === 'lockdown',
|
||||
@@ -142,7 +143,7 @@ export default function RoverQueuesPanel({
|
||||
|
||||
async function handleRequest(targetRoverId) {
|
||||
if (!targetRoverId) return;
|
||||
if (externalBlocked) return;
|
||||
if (!canRequest) return;
|
||||
if (externalMode) {
|
||||
/*
|
||||
External queue cards deliberately reuse the local row layout, but their
|
||||
@@ -252,7 +253,7 @@ export default function RoverQueuesPanel({
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
: 'Switch';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Undocked Page Exit Guard
|
||||
// Purpose: Asks the browser to confirm document-level navigation while the current driver's rover is undocked.
|
||||
// Scope: Owns the native beforeunload lifecycle and queues the matching AlertFeed explanation when leaving is attempted.
|
||||
import { useEffect } from 'react';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
|
||||
const UNDOCKED_EXIT_ALERT_LIFETIME_MS = 15 * 1000;
|
||||
|
||||
function selectHomeBaseState(frame) {
|
||||
const homeBase = frame?.sensors?.chargingSources?.homeBase;
|
||||
|
||||
/*
|
||||
Missing telemetry must remain distinct from a real "not on the home base"
|
||||
reading. Treating the initial empty frame as undocked would enable the exit
|
||||
warning before the browser has received enough information to justify it.
|
||||
*/
|
||||
return typeof homeBase === 'boolean' ? homeBase : null;
|
||||
}
|
||||
|
||||
export default function UndockedPageExitGuard() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const homeBaseState = useTelemetrySelector(roverId, selectHomeBaseState);
|
||||
const { pushAlert } = useSessionActions();
|
||||
const shouldConfirmExit = Boolean(roverId) && homeBaseState === false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldConfirmExit) return undefined;
|
||||
|
||||
function handleBeforeUnload(event) {
|
||||
/*
|
||||
Browsers intentionally own the wording and presentation of this dialog.
|
||||
preventDefault is the modern signal, while assigning returnValue keeps
|
||||
the confirmation working in browsers that still require the legacy part
|
||||
of the beforeunload contract.
|
||||
*/
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
|
||||
/*
|
||||
If the driver cancels the browser-owned confirmation, React remains
|
||||
mounted and AlertFeed presents the actionable reason for the warning.
|
||||
A stable id refreshes this one warning lane on another exit attempt
|
||||
instead of stacking duplicate cards over the driving interface.
|
||||
*/
|
||||
pushAlert({
|
||||
id: 'undocked-page-exit-warning',
|
||||
kind: 'undocked-exit-warning',
|
||||
lifetimeMs: UNDOCKED_EXIT_ALERT_LIFETIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
/*
|
||||
Removing the listener as soon as the rover docks or the assignment ends
|
||||
prevents ordinary spectators and completed drivers from seeing a stale
|
||||
leave-page confirmation.
|
||||
*/
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [pushAlert, shouldConfirmExit]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import useLayoutMode from '../hooks/useLayoutMode.js';
|
||||
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
|
||||
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
|
||||
|
||||
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
|
||||
function DriverPageRoot({ oldDesktop = false }) {
|
||||
@@ -92,7 +91,6 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<RoomAudioPlayer />
|
||||
<UndockedPageExitGuard />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||
|
||||
@@ -55,12 +55,12 @@ function CommunityColumn() {
|
||||
<div className="h-96 min-h-56">
|
||||
<ChatPanel fillHeight />
|
||||
</div>
|
||||
<OverseerPreferencePanel />
|
||||
<ReplaySourcesPanel panelId="newdrive-replay-sources" />
|
||||
<RoverQueuesPanel title="Rovers" />
|
||||
<LinkButtonsPanel fillHeight={false} />
|
||||
<PtzQueueCard layout="desktop" />
|
||||
<RawUserPilePanel compact hideNicknameForm />
|
||||
<OverseerPreferencePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,5 +40,8 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal
|
||||
export function openExternalRover(instance, roverId, { includeSettings = true } = {}) {
|
||||
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
|
||||
if (!targetUrl) return;
|
||||
// These queue actions are buttons, so let the page guard veto navigation
|
||||
// before beforeunload would pause painting behind the native browser dialog.
|
||||
if (!window.dispatchEvent(new Event('page:navigate', { cancelable: true }))) return;
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
|
||||
+39
-29
@@ -2,7 +2,7 @@
|
||||
// Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app.
|
||||
import { lazy, StrictMode, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { createBrowserRouter, Route, RouterProvider, Routes } from 'react-router-dom'
|
||||
import './index.css'
|
||||
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
|
||||
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
|
||||
@@ -22,6 +22,7 @@ import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
import MicApp from './mic/MicApp.jsx'
|
||||
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
||||
import PageExitGuard from './components/PageExitGuard/index.jsx'
|
||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
|
||||
|
||||
@@ -41,6 +42,42 @@ function lazyRoute(element, label) {
|
||||
)
|
||||
}
|
||||
|
||||
// A data router provides one blocker for links, programmatic navigation, and history.
|
||||
const router = createBrowserRouter([{
|
||||
path: '*',
|
||||
element: (
|
||||
<>
|
||||
<AnalyticsReporter />
|
||||
<OperatingModeRouteSync />
|
||||
<PageExitGuard />
|
||||
<Routes>
|
||||
<Route path="/" element={<DriverPageRoot />} />
|
||||
{/* The retired desktop composition remains available for direct comparison
|
||||
without duplicating the driver shell or changing canonical mobile layouts. */}
|
||||
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/mic" element={<MicApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
{/*
|
||||
PTZ is a separate route so the driver layout and its replay
|
||||
panel are not mounted behind the camera controller. This
|
||||
also makes orientation changes a PTZ layout concern instead
|
||||
of a local overlay-open state owned by the driver page.
|
||||
*/}
|
||||
<Route path="/ptz" element={<PtzAppRoot />} />
|
||||
<Route
|
||||
path="/reports"
|
||||
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
|
||||
/>
|
||||
</Routes>
|
||||
</>
|
||||
),
|
||||
}])
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<AppErrorBoundary>
|
||||
<StrictMode>
|
||||
@@ -55,34 +92,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<SettingsProvider>
|
||||
<ChatProvider>
|
||||
<DeterrenceChaos />
|
||||
<BrowserRouter>
|
||||
<AnalyticsReporter />
|
||||
<OperatingModeRouteSync />
|
||||
<Routes>
|
||||
<Route path="/" element={<DriverPageRoot />} />
|
||||
{/* The retired desktop composition remains available for direct comparison
|
||||
without duplicating the driver shell or changing canonical mobile layouts. */}
|
||||
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/mic" element={<MicApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
{/*
|
||||
PTZ is a separate route so the driver layout and its replay
|
||||
panel are not mounted behind the camera controller. This
|
||||
also makes orientation changes a PTZ layout concern instead
|
||||
of a local overlay-open state owned by the driver page.
|
||||
*/}
|
||||
<Route path="/ptz" element={<PtzAppRoot />} />
|
||||
<Route
|
||||
path="/reports"
|
||||
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
</ChatProvider>
|
||||
</SettingsProvider>
|
||||
</TelemetryProvider>
|
||||
|
||||
Reference in New Issue
Block a user