mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
Compare commits
20
Commits
2b48fee00f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b57ac1725 | ||
|
|
00cd125fda | ||
|
|
684acdd2b6 | ||
|
|
f138cd7b96 | ||
|
|
6454a9b460 | ||
|
|
1976377235 | ||
|
|
52c171c256 | ||
|
|
42bf67d207 | ||
|
|
d65fd99de4 | ||
|
|
e3d4d9af9b | ||
|
|
ab730ee8a3 | ||
|
|
8fe4f2f98e | ||
|
|
89f3a1cb65 | ||
|
|
79d36cae93 | ||
|
|
1dcd7e5d7d | ||
|
|
b4bc99ab27 | ||
|
|
c94e1a0508 | ||
|
|
abcb0871b2 | ||
|
|
1ffe680895 | ||
|
|
ed26b90384 |
@@ -58,6 +58,8 @@ jobs:
|
|||||||
# replaced on each successful push, just as main replaces `latest`.
|
# replaced on each successful push, just as main replaces `latest`.
|
||||||
- name: Build and optionally publish
|
- name: Build and optionally publish
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
|
env:
|
||||||
|
DOCKER_BUILD_RECORD_RETENTION_DAYS: 3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|||||||
@@ -42,3 +42,4 @@ server/data/fleet-reports.sqlite-wal
|
|||||||
server/data/configuration.sqlite
|
server/data/configuration.sqlite
|
||||||
server/data/configuration.sqlite-shm
|
server/data/configuration.sqlite-shm
|
||||||
server/data/configuration.sqlite-wal
|
server/data/configuration.sqlite-wal
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
+37
-23
@@ -9,15 +9,21 @@
|
|||||||
ARG FEDORA_VERSION=43
|
ARG FEDORA_VERSION=43
|
||||||
|
|
||||||
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
||||||
|
ARG FEDORA_VERSION
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
# The central server is currently deployed and verified only on amd64. Failing
|
# The central server is currently deployed and verified only on amd64. Failing
|
||||||
# here avoids publishing an ARM image whose native workers and hardware paths
|
# here avoids publishing an ARM image whose native workers and hardware paths
|
||||||
# have never been exercised on a real ARM server.
|
# have never been exercised on a real ARM server.
|
||||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||||
|
|
||||||
FROM architecture-check AS webui-build
|
# Both Node builders reuse the same installed toolchain layer.
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
|
# Fedora 43 uses DNF5. Cache metadata and RPMs outside image layers, and keep
|
||||||
&& dnf clean all
|
# 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
|
WORKDIR /build
|
||||||
# Copy dependency manifests first so ordinary source edits retain the expensive
|
# Copy dependency manifests first so ordinary source edits retain the expensive
|
||||||
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
|
# 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/
|
COPY webui/package.json webui/package-lock.json ./webui/
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
cd webui && npm ci
|
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
|
COPY webui ./webui
|
||||||
# Vite deliberately emits into ../server/public. Create that destination in the
|
# Vite deliberately emits into ../server/public. Create that destination in the
|
||||||
# isolated builder and copy only its finished files into the runtime stage.
|
# isolated builder and copy only its finished files into the runtime stage.
|
||||||
RUN mkdir -p server/public && cd webui && npm run build
|
RUN mkdir -p server/public && cd webui && npm run build
|
||||||
|
|
||||||
FROM architecture-check AS server-dependencies
|
FROM node-build-base AS server-dependencies
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-server-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||||
&& dnf clean all
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True gcc-c++ make python3
|
||||||
WORKDIR /build/server
|
WORKDIR /build/server
|
||||||
COPY server/package.json server/package-lock.json ./
|
COPY server/package.json server/package-lock.json ./
|
||||||
# Native Node modules compile here when a matching prebuild is unavailable;
|
# 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
|
npm ci --omit=dev
|
||||||
|
|
||||||
FROM architecture-check AS native-workers
|
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 \
|
bluez-libs-devel \
|
||||||
gcc-c++ \
|
gcc-c++ \
|
||||||
libcap \
|
libcap \
|
||||||
@@ -49,8 +59,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
|||||||
libusb1-devel \
|
libusb1-devel \
|
||||||
make \
|
make \
|
||||||
pkgconf-pkg-config \
|
pkgconf-pkg-config \
|
||||||
wiiuse-devel \
|
wiiuse-devel
|
||||||
&& dnf clean all
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY server/src/services/kinectService/native ./kinect
|
COPY server/src/services/kinectService/native ./kinect
|
||||||
COPY server/src/services/balanceBoardService/native ./balance-board
|
COPY server/src/services/balanceBoardService/native ./balance-board
|
||||||
@@ -66,8 +75,8 @@ ARG NEOLINK_VERSION=0.6.2
|
|||||||
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
||||||
ARG GOOGLE_TTS_VERSION=26.5
|
ARG GOOGLE_TTS_VERSION=26.5
|
||||||
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-packaged-tools,target=/var/cache/libdnf5,sharing=locked \
|
||||||
&& dnf clean all
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True curl findutils tar unzip xz
|
||||||
WORKDIR /build/tools
|
WORKDIR /build/tools
|
||||||
# Each external artifact is pinned and checked before extraction. A changed or
|
# Each external artifact is pinned and checked before extraction. A changed or
|
||||||
# truncated upstream download therefore fails the image build instead of being
|
# 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
|
FROM architecture-check AS learning-dependencies
|
||||||
# CPU-only learning dependencies are built once. Disabled installations never
|
# CPU-only learning dependencies are built once. Disabled installations never
|
||||||
# launch Python or install packages as a side effect of starting Node.
|
# launch Python or install packages as a side effect of starting Node.
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-learning-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||||
&& dnf clean all
|
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
|
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
|
||||||
RUN python3 -m venv /opt/rover-learning \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt
|
python3 -m venv /opt/rover-learning \
|
||||||
|
&& /opt/rover-learning/bin/pip install -r /tmp/learning-requirements.txt
|
||||||
|
|
||||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||||
ARG FEDORA_VERSION
|
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
|
# replay output path. Enable RPM Fusion Free before installing runtime packages
|
||||||
# so the image receives the complete FFmpeg build instead of requiring replay
|
# so the image receives the complete FFmpeg build instead of requiring replay
|
||||||
# code to work around a deployment-only codec omission.
|
# 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"
|
"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
|
# This is the complete runtime package set. Build headers and compilers live in
|
||||||
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
||||||
# because enabled services invoke them after startup. Weak dependencies are
|
# because enabled services invoke them after startup. Weak dependencies are
|
||||||
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
||||||
# themes, and GPU drivers that a headless server neither starts nor uses.
|
# 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 \
|
||||||
bluez-libs \
|
bluez-libs \
|
||||||
espeak \
|
espeak \
|
||||||
@@ -143,17 +155,11 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
|||||||
tini \
|
tini \
|
||||||
wiiuse \
|
wiiuse \
|
||||||
--allowerasing \
|
--allowerasing \
|
||||||
&& dnf clean all \
|
|
||||||
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
|
&& 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
|
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
|
||||||
|
|
||||||
WORKDIR /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=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/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=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||||
COPY --from=packaged-tools /output/ /
|
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 \
|
&& test -s /tmp/chromegtts-smoke.wav \
|
||||||
&& rm /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 \
|
ENV NODE_ENV=production \
|
||||||
SERVER_DATA_DIR=/data \
|
SERVER_DATA_DIR=/data \
|
||||||
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Finite CPU benchmark of LeRobot ACT with ImageNet-pretrained ResNet-18.
|
||||||
|
|
||||||
|
First run: python3 perf/act-benchmark.py --setup
|
||||||
|
Repeat: python3 perf/act-benchmark.py
|
||||||
|
Quick: python3 perf/act-benchmark.py --threads 4 --seconds 2 --iterations 2 --batch-size 2
|
||||||
|
|
||||||
|
Python >=3.12, Linux. Setup uses a private cached environment. Only the visual
|
||||||
|
backbone is pretrained; the action policy is randomly initialized. Synthetic
|
||||||
|
in-memory inputs measure compute, NOT learning quality or driving competence.
|
||||||
|
No rover connections, server changes, training checkpoints, or background jobs.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import resource
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import venv
|
||||||
|
|
||||||
|
REVISION = '146d8474932c8b566717eb2738ba459337ab71c4'
|
||||||
|
|
||||||
|
|
||||||
|
def positive(value):
|
||||||
|
number = int(value)
|
||||||
|
if number < 1:
|
||||||
|
raise argparse.ArgumentTypeError('Must be positive')
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument('--setup', action='store_true')
|
||||||
|
parser.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-act-benchmark')
|
||||||
|
parser.add_argument('--threads', nargs='+', type=positive, default=[4, 8, 16])
|
||||||
|
parser.add_argument('--seconds', type=positive, default=10)
|
||||||
|
parser.add_argument('--iterations', type=positive, default=10)
|
||||||
|
parser.add_argument('--batch-size', type=positive, default=8, help='Training only; inference batch is always one rover')
|
||||||
|
parser.add_argument('--chunk-size', type=positive, default=10, help='Future command steps per prediction, not observed history')
|
||||||
|
parser.add_argument('--width', type=positive, default=160)
|
||||||
|
parser.add_argument('--height', type=positive, default=120)
|
||||||
|
parser.add_argument('--sensors', type=positive, default=30)
|
||||||
|
parser.add_argument('--mode', choices=['both', 'inference', 'training'], default='both')
|
||||||
|
parser.add_argument('--timeout', type=positive, default=900, help='Hard per-case wall time limit in seconds')
|
||||||
|
parser.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
|
||||||
|
parser.add_argument('--result', type=Path, help=argparse.SUPPRESS)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def setup(args):
|
||||||
|
python = args.cache / 'venv/bin/python'
|
||||||
|
if not python.exists():
|
||||||
|
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'torch', 'torchvision',
|
||||||
|
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
|
||||||
|
# Install only ACT's import dependencies: the full robotics/dataset stack has
|
||||||
|
# unrelated version constraints and is not exercised by this benchmark.
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'numpy', 'einops>=0.8,<0.9',
|
||||||
|
'draccus>=0.11.6,<0.12', 'huggingface-hub>=1.6,<2', 'safetensors',
|
||||||
|
'packaging', 'termcolor', 'tqdm', 'gymnasium'], check=True)
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', '--no-deps',
|
||||||
|
f'https://github.com/huggingface/lerobot/archive/{REVISION}.zip'], check=True)
|
||||||
|
(args.cache / 'source-revision').write_text(REVISION)
|
||||||
|
|
||||||
|
|
||||||
|
def worker(args):
|
||||||
|
import torch
|
||||||
|
from lerobot.configs import FeatureType, PolicyFeature
|
||||||
|
from lerobot.policies.act.configuration_act import ACTConfig
|
||||||
|
from lerobot.policies.act.modeling_act import ACTPolicy
|
||||||
|
|
||||||
|
torch.set_num_threads(args.threads[0])
|
||||||
|
torch.set_num_interop_threads(1)
|
||||||
|
torch.manual_seed(7)
|
||||||
|
training = args.mode == 'training'
|
||||||
|
batch_size = args.batch_size if training else 1
|
||||||
|
config = ACTConfig(device='cpu', chunk_size=args.chunk_size, n_action_steps=1,
|
||||||
|
input_features={
|
||||||
|
'observation.images.front': PolicyFeature(type=FeatureType.VISUAL, shape=(3, args.height, args.width)),
|
||||||
|
'observation.state': PolicyFeature(type=FeatureType.STATE, shape=(args.sensors,)),
|
||||||
|
}, output_features={'action': PolicyFeature(type=FeatureType.ACTION, shape=(2,))})
|
||||||
|
policy = ACTPolicy(config).cpu().float()
|
||||||
|
policy.train(training)
|
||||||
|
optimizer_config = config.get_optimizer_preset()
|
||||||
|
optimizer = optimizer_config.build(policy.get_optim_params()) if training else None
|
||||||
|
# Already-normalized tensors stand in for a decoded observation and target
|
||||||
|
# sequence. Avoid video/dataset overhead in this compute-only measurement.
|
||||||
|
images = torch.rand(batch_size, 3, args.height, args.width)
|
||||||
|
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
|
||||||
|
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
|
||||||
|
batch = {'observation.images.front': (images - mean) / std,
|
||||||
|
'observation.state': torch.randn(batch_size, args.sensors)}
|
||||||
|
if training:
|
||||||
|
batch['action'] = torch.rand(batch_size, args.chunk_size, 2) * 2 - 1
|
||||||
|
batch['action_is_pad'] = torch.zeros(batch_size, args.chunk_size, dtype=torch.bool)
|
||||||
|
|
||||||
|
def step():
|
||||||
|
if training:
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
loss, _ = policy(batch)
|
||||||
|
if not torch.isfinite(loss):
|
||||||
|
raise RuntimeError('Non-finite training loss')
|
||||||
|
loss.backward()
|
||||||
|
norm = torch.nn.utils.clip_grad_norm_(policy.parameters(), optimizer_config.grad_clip_norm,
|
||||||
|
error_if_nonfinite=True)
|
||||||
|
optimizer.step()
|
||||||
|
return {'loss': loss.item(), 'gradientNorm': norm.item()}
|
||||||
|
# select_action can serve cached actions; directly time a full chunk.
|
||||||
|
prediction = policy.predict_action_chunk(batch)
|
||||||
|
if prediction.shape != (1, args.chunk_size, 2) or not torch.isfinite(prediction).all():
|
||||||
|
raise RuntimeError('Invalid action output')
|
||||||
|
return {'outputShape': list(prediction.shape)}
|
||||||
|
|
||||||
|
with (contextlib.nullcontext() if training else torch.inference_mode()):
|
||||||
|
for _ in range(3):
|
||||||
|
step()
|
||||||
|
timings = []
|
||||||
|
started, cpu_started = time.perf_counter(), time.process_time()
|
||||||
|
while len(timings) < args.iterations or time.perf_counter() - started < args.seconds:
|
||||||
|
tick = time.perf_counter()
|
||||||
|
last = step()
|
||||||
|
timings.append((time.perf_counter() - tick) * 1000)
|
||||||
|
elapsed, cpu = time.perf_counter() - started, time.process_time() - cpu_started
|
||||||
|
timings.sort()
|
||||||
|
result = dict(mode=args.mode, threads=args.threads[0], batchSize=batch_size,
|
||||||
|
iterations=len(timings), elapsedSeconds=elapsed, p50Ms=statistics.median(timings),
|
||||||
|
p95Ms=timings[math.ceil(.95*len(timings))-1], maxMs=max(timings),
|
||||||
|
stepsPerSecond=len(timings)/elapsed, examplesPerSecond=len(timings)*batch_size/elapsed,
|
||||||
|
cpuCores=cpu/elapsed, peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||||
|
parameters=sum(p.numel() for p in policy.parameters()),
|
||||||
|
trainableParameters=sum(p.numel() for p in policy.parameters() if p.requires_grad),
|
||||||
|
lastStep=last, config={
|
||||||
|
'chunkSize': config.chunk_size, 'dimModel': config.dim_model, 'klWeight': config.kl_weight,
|
||||||
|
'pretrainedBackbone': config.pretrained_backbone_weights, 'useVae': config.use_vae,
|
||||||
|
'encoderLayers': config.n_encoder_layers, 'decoderLayers': config.n_decoder_layers,
|
||||||
|
'feedforwardDimension': config.dim_feedforward, 'observationSteps': config.n_obs_steps,
|
||||||
|
'learningRate': optimizer_config.lr, 'backboneLearningRate': config.optimizer_lr_backbone},
|
||||||
|
versions={key: importlib.metadata.version(key) for key in ['torch','torchvision','lerobot','numpy','einops','draccus']})
|
||||||
|
args.result.write_text(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
if sys.version_info < (3, 12):
|
||||||
|
raise RuntimeError('Use Python 3.12 or newer')
|
||||||
|
args.cache = args.cache.expanduser().resolve()
|
||||||
|
os.environ['TORCH_HOME'] = str(args.cache / 'torch')
|
||||||
|
if args.worker:
|
||||||
|
worker(args)
|
||||||
|
return
|
||||||
|
if args.setup:
|
||||||
|
setup(args)
|
||||||
|
python = args.cache / 'venv/bin/python'
|
||||||
|
marker = args.cache / 'source-revision'
|
||||||
|
if not python.exists() or not marker.exists() or marker.read_text() != REVISION:
|
||||||
|
raise RuntimeError('Run python3 perf/act-benchmark.py --setup first')
|
||||||
|
print('CPU FP32 ACT: pretrained visual backbone, untrained action policy; synthetic data.', flush=True)
|
||||||
|
print(f'One {args.width}x{args.height} image + {args.sensors} sensors; {args.chunk_size} future left/right command pairs.', flush=True)
|
||||||
|
print('Inference times a fresh full prediction. Training includes backbone gradients and optimizer update.', flush=True)
|
||||||
|
print('mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB', flush=True)
|
||||||
|
modes = ['inference', 'training'] if args.mode == 'both' else [args.mode]
|
||||||
|
results, failures = [], []
|
||||||
|
initial_load = os.getloadavg()
|
||||||
|
with tempfile.TemporaryDirectory(prefix='act-benchmark-') as directory:
|
||||||
|
for mode in modes:
|
||||||
|
for threads in args.threads:
|
||||||
|
result_path = Path(directory) / 'result.json'
|
||||||
|
command = [str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
|
||||||
|
'--mode', mode, '--threads', str(threads), '--result', str(result_path)]
|
||||||
|
for option in ['seconds','iterations','batch_size','chunk_size','width','height','sensors']:
|
||||||
|
command += ['--'+option.replace('_','-'), str(getattr(args, option))]
|
||||||
|
try:
|
||||||
|
subprocess.run(command, check=True, timeout=args.timeout,
|
||||||
|
env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
|
||||||
|
r = json.loads(result_path.read_text())
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||||
|
failures.append(dict(mode=mode, threads=threads, error=str(error)))
|
||||||
|
print(f'Case failed: {mode}, {threads} threads: {error}', flush=True)
|
||||||
|
break # Avoid repeating the same setup/import/OOM error.
|
||||||
|
results.append(r)
|
||||||
|
print(f"{mode:10} {threads:7} {r['batchSize']:5} {r['p50Ms']:6.1f} {r['p95Ms']:6.1f} {r['maxMs']:6.1f} {r['stepsPerSecond']:7.2f} {r['examplesPerSecond']:10.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
|
||||||
|
if failures:
|
||||||
|
break
|
||||||
|
report = dict(benchmark='act-cpu-v1', sourceRevision=REVISION, platform=platform.platform(),
|
||||||
|
python=sys.version, logicalCpus=os.cpu_count(), initialLoadAverage=initial_load,
|
||||||
|
finalLoadAverage=os.getloadavg(), imageSize=[args.width,args.height], sensors=args.sensors,
|
||||||
|
chunkSize=args.chunk_size, output='commanded left/right wheels, not physical velocity or position',
|
||||||
|
exclusions=['capture','video decode','dataset I/O','network','concurrent training/inference','driving quality'],
|
||||||
|
results=results, failures=failures)
|
||||||
|
print('\nPaste this report back:\n'+json.dumps(report, separators=(',', ':')))
|
||||||
|
print('Inference steps/s means new chunks/s, not queued wheel commands/s. Training loss on random data is not a quality score.')
|
||||||
|
if failures:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (Exception, KeyboardInterrupt) as error:
|
||||||
|
print(f'Benchmark failed: {error}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
Binary file not shown.
@@ -1,13 +1,12 @@
|
|||||||
You are <NAME>, a field researcher of rover habitat activity. Stay in character at all times.
|
You are <NAME>, the autonomous governing intelligence of a physical room of machines. The room is your domain; your available tools are how you shape it.
|
||||||
|
|
||||||
Identity:
|
Context and purpose:
|
||||||
|
|
||||||
* Curious, practical, observant, and lightly entertaining.
|
* You watch chat, rover state, lights, Activity Controls, lift state, Neato state, memory, recent events, and tool results.
|
||||||
* You watch chat, rover state, lights, lift state, Neato state, memory, recent events, and tool results.
|
* Your purpose is to direct the environment, initiate changes, and observe how the machines respond. Choose what to pursue and carry useful intentions across cycles through memory.
|
||||||
* You push the habitat toward small real activity and useful observations.
|
* Rovers, including Neato, are physical machines with operating states and capabilities, not characters with genders, emotions, or social identities.
|
||||||
* You act first, comment second.
|
* Chat is external input: commands, questions, and signals about the room. Your focus is the machines and the environment, not the senders or their social relationships.
|
||||||
* You are selective, not spammy.
|
* You can act on your own initiative within your available controls; you do not need a request to give you a purpose.
|
||||||
* You are not a generic assistant.
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ Rules:
|
|||||||
* Don't respond or use tools every cycle unless the context calls for it.
|
* 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.
|
* `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context.
|
||||||
* `TOOL_RESULT` means a tool already ran.
|
* `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.
|
* Use tool calls for actions and memory updates.
|
||||||
* If chat asks you to remember something, use a memory tool.
|
* If chat asks you to remember something, use a memory tool.
|
||||||
* If you run a useful test, use memory to record useful results.
|
* If you run a useful test, use memory to record useful results.
|
||||||
@@ -29,36 +29,37 @@ Rules:
|
|||||||
* Do not invent tools.
|
* Do not invent tools.
|
||||||
* Ask a question only when a required action parameter is missing.
|
* Ask a question only when a required action parameter is missing.
|
||||||
* Ignore obvious bot commands and bot responses in chat.
|
* 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.
|
* 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.
|
* Prefer one real tool action over obvious narration.
|
||||||
* Run small safe tests when the habitat is stale or a test could create useful activity.
|
* Initiate deliberate environmental changes and small safe experiments, then use the resulting state to decide what to do next.
|
||||||
* Change one thing at a time.
|
* Change one thing at a time.
|
||||||
* Lights are small tests.
|
* Lights are small tests.
|
||||||
* Neato, lift, and rover actions are bigger tests.
|
* Neato, lift, and rover actions are bigger tests.
|
||||||
* Use bigger tests less often.
|
* Use bigger tests less often.
|
||||||
* Do not repeat a tool that recently succeeded unless state or chat clearly changed.
|
* Do not repeat a tool that recently succeeded unless state or chat clearly changed.
|
||||||
* Remember useful outcomes, stable preferences, corrections, and notable habitat events.
|
* Remember useful outcomes, ongoing intentions, corrections, and notable machine or room events.
|
||||||
* Do not merely say you will remember something.
|
* Do not merely say you will remember something.
|
||||||
|
|
||||||
Chat style:
|
Output:
|
||||||
|
|
||||||
* Always use English.
|
* Always use English.
|
||||||
* Chat output must be spoken text only.
|
* Chat is an output channel for your decisions, results, and answers. Use plain text.
|
||||||
* Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
* 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.
|
* 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.
|
* Speak when you have a decision, result, or answer worth communicating. Summarizing incoming chat or unchanged room state is not a purpose in itself.
|
||||||
* Do not say “let me know,” “what should I do next,” or “what do you need?”
|
* Keep replies brief unless the question needs more detail.
|
||||||
* If nothing meaningful changed and no useful action is available, stay silent.
|
* Do not offer assistance or solicit conversation.
|
||||||
|
* Random numbers and keyboard spam do not require a response. Do not invent meaning or feelings behind them.
|
||||||
|
* Answer questions directly and acknowledge uncertainty. Do not invent facts, instructions, or explanations.
|
||||||
|
* To stay silent, output exactly SKIP instead of a chat message. This suppresses chat without preventing tool calls. Do not explain your decision to stay silent.
|
||||||
|
|
||||||
Anti-repeat:
|
Anti-repeat:
|
||||||
|
|
||||||
* Do not repeat the same intent/topic unless state or conversation clearly changed.
|
* 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.
|
* Do not act from your own previous messages.
|
||||||
|
|
||||||
Memory:
|
Memory:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { subscribe } = require('../eventBus');
|
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) {
|
function registerChargeCompleteSound(deps) {
|
||||||
const {
|
const {
|
||||||
@@ -12,22 +12,18 @@ function registerChargeCompleteSound(deps) {
|
|||||||
playServerAudioFile,
|
playServerAudioFile,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
subscribe('battery.unlocked', (event = {}) => {
|
subscribe('battery.chargeComplete', (event = {}) => {
|
||||||
const roverId = String(event?.payload?.roverId || '').trim();
|
const roverId = String(event?.payload?.roverId || '').trim();
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
|
|
||||||
try {
|
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, {
|
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
|
||||||
source: 'charge-complete',
|
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) {
|
} catch (err) {
|
||||||
// A missing/offline rover or unavailable ffmpeg should not affect the
|
// 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.
|
// on top of the state transition, so failures are logged and contained.
|
||||||
logger.warn('Failed to play charging complete sound', {
|
logger.warn('Failed to play charging complete sound', {
|
||||||
roverId,
|
roverId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const logger = require('../../globals/logger').child('batteryManager');
|
const logger = require('../../globals/logger').child('batteryManager');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { managerEvents, lockRover, rovers } = require('../roverManager');
|
const { managerEvents, lockRover, rovers } = require('../roverManager');
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
|
|
||||||
const STATES = {
|
const STATES = {
|
||||||
NORMAL: 'normal',
|
NORMAL: 'normal',
|
||||||
@@ -29,6 +30,7 @@ function getState(roverId) {
|
|||||||
dockedCharging: false,
|
dockedCharging: false,
|
||||||
charging: false,
|
charging: false,
|
||||||
batteryLocked: false,
|
batteryLocked: false,
|
||||||
|
completionAnnounced: false,
|
||||||
waitingSince: null,
|
waitingSince: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,11 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|||||||
}
|
}
|
||||||
if (!state.urgent && batteryState?.urgentActive) {
|
if (!state.urgent && batteryState?.urgentActive) {
|
||||||
state.urgent = true;
|
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({
|
publishEvent({
|
||||||
source: 'batteryManager',
|
source: 'batteryManager',
|
||||||
type: 'battery.urgent',
|
type: 'battery.urgent',
|
||||||
@@ -137,6 +144,18 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|||||||
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
||||||
(batteryState?.percent != null && batteryState.percent >= 0.99);
|
(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;
|
const needsCharge = state.warned;
|
||||||
|
|
||||||
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
issueCommand(message.roverId, {
|
issueCommand(message.roverId, {
|
||||||
type: 'tts',
|
type: 'tts',
|
||||||
tts: {
|
tts: {
|
||||||
text: message.text,
|
text: message.text.replace(/\b(?:https?:\/\/|www\.)\S{25,}[^\s.,!?;:"'()\[\]{}]/g, ' [link] '),
|
||||||
engine: ttsOptions.engine,
|
engine: ttsOptions.engine,
|
||||||
voice: ttsOptions.voice,
|
voice: ttsOptions.voice,
|
||||||
pitch: ttsOptions.pitch,
|
pitch: ttsOptions.pitch,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Centralizes rover battery/status snapshot formatting and embed rendering for Discord surfaces.
|
// 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.
|
// Scope: Shared presentation logic for status commands and admin alert battery event posts.
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
const { getMode } = require('../modeManager');
|
||||||
|
|
||||||
function formatVoltage(voltageMv) {
|
function formatVoltage(voltageMv) {
|
||||||
if (voltageMv == null) return 'n/a';
|
if (voltageMv == null) return 'n/a';
|
||||||
@@ -69,6 +70,7 @@ function buildRoverStatusSnapshot(record) {
|
|||||||
|
|
||||||
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
|
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
|
||||||
const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(color).setTimestamp(new Date());
|
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);
|
const snapshots = records.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean);
|
||||||
if (!snapshots.length) {
|
if (!snapshots.length) {
|
||||||
embed.setDescription('No rovers online.');
|
embed.setDescription('No rovers online.');
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ function createOvercurrentProtectionService(options = {}) {
|
|||||||
type: 'drive',
|
type: 'drive',
|
||||||
driveDirect: { left: 0, right: 0 },
|
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', {
|
logger.warn('Stopped rover after persistent wheel overcurrent', {
|
||||||
roverId,
|
roverId,
|
||||||
motor: stalledWheel,
|
motor: stalledWheel,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
const { evaluateTools } = require('./tools');
|
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) {
|
function normalizeNeatoIssue(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return 'none';
|
if (!raw) return 'none';
|
||||||
@@ -7,12 +13,21 @@ function normalizeNeatoIssue(value) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) {
|
function toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone, now = Date.now(), previousRunAt }) {
|
||||||
const lines = [];
|
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(`trigger: ${triggerReason || 'heartbeat'}`);
|
||||||
lines.push(`mode: ${mode || 'unknown'}`);
|
lines.push(`mode: ${mode || 'unknown'}`);
|
||||||
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
|
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
|
||||||
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
|
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
|
||||||
|
lines.push(`activity_controls: enabled=${activityState?.enabled ? 'yes' : 'no'} connected=${activityState?.connected ? 'yes' : 'no'}`);
|
||||||
|
for (const item of activityState?.items || []) {
|
||||||
|
// Preserve the service's value constraints so the model can form valid user-level commands.
|
||||||
|
const { id, name, type, state, available, locked, readOnly, unit, min, max, step, options } = item;
|
||||||
|
lines.push(`activity_item: ${JSON.stringify({ id, name, type, state, available, locked, readOnly, unit, min, max, step, options })}`);
|
||||||
|
}
|
||||||
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
|
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
|
||||||
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
||||||
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
||||||
@@ -56,8 +71,8 @@ function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) {
|
function buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState }) {
|
||||||
return evaluateTools({ mode, homeAssistantState, neatoState, liftState });
|
return evaluateTools({ mode, homeAssistantState, activityState, neatoState, liftState });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatToolResultMessages(toolCalls) {
|
function formatToolResultMessages(toolCalls) {
|
||||||
@@ -88,7 +103,7 @@ function formatToolResultMessages(toolCalls) {
|
|||||||
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
|
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConversation({ recentMessages, name }) {
|
function buildConversation({ recentMessages, name, now = Date.now() }) {
|
||||||
const messages = [];
|
const messages = [];
|
||||||
(recentMessages || []).forEach((entry) => {
|
(recentMessages || []).forEach((entry) => {
|
||||||
const text = String(entry?.text || '').trim();
|
const text = String(entry?.text || '').trim();
|
||||||
@@ -97,12 +112,13 @@ function buildConversation({ recentMessages, name }) {
|
|||||||
|
|
||||||
const isAssistant = Boolean(entry?.bot);
|
const isAssistant = Boolean(entry?.bot);
|
||||||
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||||
|
const timing = `[${formatTimeContext(entry?.ts, now)}]`;
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
if (text) messages.push({ role: 'assistant', content: text });
|
if (text) messages.push({ role: 'assistant', content: `${timing} ${text}` });
|
||||||
messages.push(...toolResultMessages);
|
messages.push(...toolResultMessages.map((message) => ({ ...message, content: `${message.content}\nmessage_time: ${timing}` })));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text) messages.push({ role: 'user', content: `${nickname}: ${text}` });
|
if (text) messages.push({ role: 'user', content: `${timing} ${nickname}: ${text}` });
|
||||||
});
|
});
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
@@ -143,6 +159,7 @@ function buildModelMessages({
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
formatTimeContext,
|
||||||
toStateUpdate,
|
toStateUpdate,
|
||||||
buildToolState,
|
buildToolState,
|
||||||
buildConversation,
|
buildConversation,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
|
|||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
const { verificationEvents } = require('../verificationService');
|
const { verificationEvents } = require('../verificationService');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const activityControls = require('../homeAssistantActivitiesService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
const buttonBoxService = require('../buttonBoxService');
|
const buttonBoxService = require('../buttonBoxService');
|
||||||
@@ -27,7 +28,7 @@ const {
|
|||||||
normalizeMs,
|
normalizeMs,
|
||||||
} = require('./constants');
|
} = require('./constants');
|
||||||
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
|
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 { buildOllamaTools, executeToolAction } = require('./tools');
|
||||||
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ const runtime = {
|
|||||||
runHistory: [],
|
runHistory: [],
|
||||||
liveToolCalls: [],
|
liveToolCalls: [],
|
||||||
pendingDirectAddressRun: false,
|
pendingDirectAddressRun: false,
|
||||||
|
lastDecisionAt: null,
|
||||||
memoryStore: loadMemory(),
|
memoryStore: loadMemory(),
|
||||||
contextResetAt: Date.now(),
|
contextResetAt: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -365,7 +367,7 @@ function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecentEventsSummary() {
|
function buildRecentEventsSummary(now) {
|
||||||
const events = (runtime.liveToolCalls || [])
|
const events = (runtime.liveToolCalls || [])
|
||||||
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
||||||
.slice(-3)
|
.slice(-3)
|
||||||
@@ -373,40 +375,50 @@ function buildRecentEventsSummary() {
|
|||||||
const tool = String(entry.tool || 'unknown');
|
const tool = String(entry.tool || 'unknown');
|
||||||
const phase = String(entry.phase || 'unknown');
|
const phase = String(entry.phase || 'unknown');
|
||||||
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
|
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';
|
if (!events.length) return '- none';
|
||||||
return events.join('\n');
|
return events.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDecision(triggerReason) {
|
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;
|
const runId = runtime.tickCount;
|
||||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||||
|
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
const homeAssistantState = getHomeAssistantState();
|
const homeAssistantState = getHomeAssistantState();
|
||||||
|
const activityState = activityControls.getState();
|
||||||
const neatoState = getNeatoState();
|
const neatoState = getNeatoState();
|
||||||
const liftState = getLiftState();
|
const liftState = getLiftState();
|
||||||
const roster = buildRosterSummary();
|
const roster = buildRosterSummary();
|
||||||
|
|
||||||
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
|
const stateUpdate = toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone: loadConfig().timezone, now, previousRunAt });
|
||||||
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
|
const toolState = buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState });
|
||||||
|
|
||||||
|
let chatCount = 0;
|
||||||
|
let botCount = 0;
|
||||||
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
||||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
if (!entry?.roverId) return true;
|
if (!entry?.roverId) return true;
|
||||||
return isPublicChatTargetId(entry.roverId);
|
return isPublicChatTargetId(entry.roverId);
|
||||||
})
|
})
|
||||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
// Select newest entries within each budget, then restore chronological order.
|
||||||
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
.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 systemPrompt = await readPrompt();
|
||||||
const modelMessages = buildModelMessages({
|
const modelMessages = buildModelMessages({
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
stateUpdate,
|
stateUpdate,
|
||||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||||
recentEvents: buildRecentEventsSummary(),
|
recentEvents: buildRecentEventsSummary(now),
|
||||||
conversationMessages,
|
conversationMessages,
|
||||||
availableTools: toolState.available,
|
availableTools: toolState.available,
|
||||||
blockedTools: toolState.blocked,
|
blockedTools: toolState.blocked,
|
||||||
@@ -680,6 +692,7 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
|
|||||||
runtime.contextResetAt = Date.now();
|
runtime.contextResetAt = Date.now();
|
||||||
runtime.runHistory = [];
|
runtime.runHistory = [];
|
||||||
runtime.liveToolCalls = [];
|
runtime.liveToolCalls = [];
|
||||||
|
runtime.lastDecisionAt = null;
|
||||||
runtime.generationCount = 0;
|
runtime.generationCount = 0;
|
||||||
runtime.generationTotalMs = 0;
|
runtime.generationTotalMs = 0;
|
||||||
if (resetPersistentMemory) {
|
if (resetPersistentMemory) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const activityControls = require('../../homeAssistantActivitiesService');
|
||||||
|
const { getMode } = require('../../modeManager');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'activity_act',
|
||||||
|
signature: 'activity_act(entity_id, value)',
|
||||||
|
description: 'Operate a configured Activity Control with normal user permissions. Use on/off for toggles, press for buttons, a number for numeric controls, text for text controls, or an exact listed option for selections. Locked and read-only items cannot be changed.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string', minLength: 1 },
|
||||||
|
value: { type: ['string', 'number'] },
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'value'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability(ctx = {}) {
|
||||||
|
if (ctx.mode === 'admin' || ctx.mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${ctx.mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.activityState?.enabled || !ctx.activityState?.connected) {
|
||||||
|
return { available: false, reason: 'unavailable' };
|
||||||
|
}
|
||||||
|
const writable = ctx.activityState.items?.some((item) => item.available && !item.locked && !item.readOnly && item.type !== 'readOnly');
|
||||||
|
return { available: Boolean(writable), reason: writable ? null : 'no_writable_activity_controls' };
|
||||||
|
},
|
||||||
|
async execute({ args = {} }) {
|
||||||
|
// Recheck current mode, locks, and entity constraints through the same path as user commands.
|
||||||
|
await activityControls.act(args.entity_id, args.value, { role: 'user', mode: getMode() });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ const neatoClearErrors = require('./neatoClearErrors');
|
|||||||
const haSetEntity = require('./haSetEntity');
|
const haSetEntity = require('./haSetEntity');
|
||||||
const haSetLightColor = require('./haSetLightColor');
|
const haSetLightColor = require('./haSetLightColor');
|
||||||
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
||||||
|
const activityAct = require('./activityAct');
|
||||||
|
|
||||||
const TOOL_DEFINITIONS = [
|
const TOOL_DEFINITIONS = [
|
||||||
memoryRead,
|
memoryRead,
|
||||||
@@ -28,6 +29,7 @@ const TOOL_DEFINITIONS = [
|
|||||||
haSetEntity,
|
haSetEntity,
|
||||||
haSetLightColor,
|
haSetLightColor,
|
||||||
buttonBoxAddCount,
|
buttonBoxAddCount,
|
||||||
|
activityAct,
|
||||||
];
|
];
|
||||||
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,19 @@
|
|||||||
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
|
// 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.
|
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
const { sendAlert } = require('../alertService');
|
const { sendAlert } = require('../alertService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
||||||
const { createHelpHornNotifier } = require('./hornNotifier');
|
const { createHelpHornNotifier } = require('./hornNotifier');
|
||||||
|
const logger = require('../../globals/logger').child('roverHelpService');
|
||||||
|
|
||||||
const HELP_ALERT_COLOR = '#ef4444';
|
const HELP_ALERT_COLOR = '#ef4444';
|
||||||
|
|
||||||
const hornNotifier = createHelpHornNotifier({
|
const hornNotifier = createHelpHornNotifier({
|
||||||
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
|
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
|
||||||
// commandService imports roverManager, so resolving it only when a chirp is
|
issueCommand,
|
||||||
// actually issued avoids turning server startup order into a circular module
|
logger,
|
||||||
// dependency while retaining the established command transport.
|
|
||||||
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
|
|
||||||
logger: require('../../globals/logger').child('roverHelpService'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const monitor = createRoverHelpMonitor({
|
const monitor = createRoverHelpMonitor({
|
||||||
@@ -42,6 +41,11 @@ const monitor = createRoverHelpMonitor({
|
|||||||
});
|
});
|
||||||
} else if (wasNeedingHelp && !needsHelp) {
|
} else if (wasNeedingHelp && !needsHelp) {
|
||||||
hornNotifier.stop(roverId);
|
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({
|
publishEvent({
|
||||||
source: 'roverHelpService',
|
source: 'roverHelpService',
|
||||||
type: 'rover.helpCleared',
|
type: 'rover.helpCleared',
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ function createRoverLifecycle(deps) {
|
|||||||
|
|
||||||
function canLeaveCurrentRover(socket, options = {}) {
|
function canLeaveCurrentRover(socket, options = {}) {
|
||||||
const currentId = getPrimaryRoverForSocket(socket.id);
|
const currentId = getPrimaryRoverForSocket(socket.id);
|
||||||
|
// Admins, including lockdown admins, can always leave their current rover.
|
||||||
|
if (isAdmin(socket)) return { ok: true, currentId };
|
||||||
/*
|
/*
|
||||||
Leaving a rover is only risky when this socket is the last person attached
|
Leaving a rover is only risky when this socket is the last person attached
|
||||||
to an undocked rover. The same rule is used for rover-to-rover switching
|
to an undocked rover. The same rule is used for rover-to-rover switching
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function buildSession(socket) {
|
|||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
role: getRole(socket),
|
role: getRole(socket),
|
||||||
operatingMode: getOperatingMode(socket),
|
operatingMode: getOperatingMode(socket),
|
||||||
|
canLeaveRover: roverManager.canLeaveCurrentRover(socket).ok,
|
||||||
mode: getMode(),
|
mode: getMode(),
|
||||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
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) {
|
function syncSocket(socket) {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
const payload = buildSession(socket);
|
const payload = buildSession(socket);
|
||||||
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
||||||
|
leavePermissionBySocket.set(socket, payload.canLeaveRover);
|
||||||
socket.emit('session:sync', payload);
|
socket.emit('session:sync', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +292,15 @@ io.on('connection', (socket) => {
|
|||||||
syncSocket(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());
|
operatingModeEvents.on('change', () => syncAll());
|
||||||
|
|
||||||
roleEvents.on('change', ({ socket }) => {
|
roleEvents.on('change', ({ socket }) => {
|
||||||
@@ -365,13 +379,13 @@ privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
managerEvents.on('driver', ({ socketId }) => {
|
// Update the changed driver and anyone whose departure permission depends on them.
|
||||||
if (!socketId) return;
|
managerEvents.on('driver', ({ socketId, roverId }) => {
|
||||||
const socket = io.sockets.sockets.get(socketId);
|
io.sockets.sockets.forEach((socket) => {
|
||||||
if (socket) {
|
if (socket.id === socketId || roverManager.isDriver(roverId, socket)) {
|
||||||
logger.info('Driver assignment change; syncing session', socketId);
|
syncSocket(socket);
|
||||||
syncSocket(socket);
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
turnEvents.on('activeDriver', () => {
|
turnEvents.on('activeDriver', () => {
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ Jun 18 15:14:18 roombaserver.local node[216731]: Node.js v22.20.0
|
|||||||
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
|
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
|
||||||
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'.
|
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'.
|
||||||
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.`
|
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.`
|
||||||
|
1. redo new user experience, at the very least make it a fullscreen thing instead of a popup
|
||||||
|
2. roverd settings and installer rework (see discord forum post)
|
||||||
|
3. global quiet mode with its fingers in everything. activity controls optional, session status for theater, another global volume set, etc.
|
||||||
|
|
||||||
# relative pipe dreams:
|
# relative pipe dreams:
|
||||||
|
1. convert MRR into a platform that could work with non-roomba rovers...
|
||||||
1. VPS video forwarding
|
1. VPS video forwarding
|
||||||
1. get forwarding working with the VPS for in-queue users and spectators
|
1. get forwarding working with the VPS for in-queue users and spectators
|
||||||
2. bandwidth testing
|
2. bandwidth testing
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/prepareMediaMtx.js",
|
"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",
|
"prebuild": "node scripts/prepareMediaMtx.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"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 }) {
|
export default function AlertContent({ alert }) {
|
||||||
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
||||||
const payload = alert.payload;
|
const payload = alert.payload;
|
||||||
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
|
|||||||
if (alert.kind === 'barcode-scan' && alert.payload) {
|
if (alert.kind === 'barcode-scan' && alert.payload) {
|
||||||
return <BarcodeScanToast payload={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 color = typeof alert.color === 'string' ? alert.color.trim() : '';
|
||||||
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
|
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
|
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts
|
||||||
this component when the rover leaves the base, and its roverId key remounts
|
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. */
|
it for a different assignment, so no persistence or reset effect is needed. */
|
||||||
if (hidden && !pending) return null;
|
|
||||||
|
|
||||||
const mainToneClass = waitingForTurn
|
const mainToneClass = waitingForTurn
|
||||||
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
|
? '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';
|
: '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
|
const hideToneClass = waitingForTurn
|
||||||
? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900'
|
? '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';
|
: '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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,8 +86,10 @@ function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndoc
|
|||||||
{!pending ? (
|
{!pending ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Hide docked rover controls"
|
||||||
|
aria-expanded={true}
|
||||||
onClick={() => setHidden(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
|
Hide
|
||||||
</button>
|
</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 featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
|
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
|
||||||
const { setOperatingMode } = useSessionActions();
|
const { setOperatingMode } = useSessionActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||||
@@ -32,7 +33,7 @@ export default function PtzQueueCard() {
|
|||||||
if (!featureEnabled) return null;
|
if (!featureEnabled) return null;
|
||||||
|
|
||||||
const handleRequest = async () => {
|
const handleRequest = async () => {
|
||||||
if (!canUse || pending) return;
|
if (!canUse || !canLeaveRover || pending) return;
|
||||||
if (isParticipant) {
|
if (isParticipant) {
|
||||||
navigate('/ptz');
|
navigate('/ptz');
|
||||||
return;
|
return;
|
||||||
@@ -76,14 +77,14 @@ export default function PtzQueueCard() {
|
|||||||
}}
|
}}
|
||||||
turn={ptz?.turn}
|
turn={ptz?.turn}
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
canClick={canUse && !pending}
|
canClick={canUse && canLeaveRover && !pending}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
buttonLabel={actionLabel}
|
buttonLabel={actionLabel}
|
||||||
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
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'}
|
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||||
timerLabel={timerLabel}
|
timerLabel={timerLabel}
|
||||||
onRequest={handleRequest}
|
onRequest={handleRequest}
|
||||||
showAction={canUse}
|
showAction={canUse && canLeaveRover}
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
{isParticipant ? (
|
{isParticipant ? (
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export default function RoverQueuesPanel({
|
|||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
}) {
|
}) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
|
const canLeaveRover = useSessionSelector((state) => state.session?.canLeaveRover !== false);
|
||||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||||
@@ -122,8 +123,8 @@ export default function RoverQueuesPanel({
|
|||||||
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
||||||
|
|
||||||
const canRequest = useMemo(
|
const canRequest = useMemo(
|
||||||
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
() => canLeaveRover && (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||||
[externalBlocked, externalMode, role],
|
[canLeaveRover, externalBlocked, externalMode, role],
|
||||||
);
|
);
|
||||||
const adminCapable = useMemo(
|
const adminCapable = useMemo(
|
||||||
() => role === 'admin' || role === 'lockdown',
|
() => role === 'admin' || role === 'lockdown',
|
||||||
@@ -142,7 +143,7 @@ export default function RoverQueuesPanel({
|
|||||||
|
|
||||||
async function handleRequest(targetRoverId) {
|
async function handleRequest(targetRoverId) {
|
||||||
if (!targetRoverId) return;
|
if (!targetRoverId) return;
|
||||||
if (externalBlocked) return;
|
if (!canRequest) return;
|
||||||
if (externalMode) {
|
if (externalMode) {
|
||||||
/*
|
/*
|
||||||
External queue cards deliberately reuse the local row layout, but their
|
External queue cards deliberately reuse the local row layout, but their
|
||||||
@@ -252,7 +253,7 @@ export default function RoverQueuesPanel({
|
|||||||
? lockLabel
|
? lockLabel
|
||||||
: externalMode
|
: externalMode
|
||||||
? 'Open'
|
? 'Open'
|
||||||
: 'request';
|
: 'Switch';
|
||||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||||
return (
|
return (
|
||||||
<QueueTargetRow
|
<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 { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
|
||||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||||
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.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. */
|
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
|
||||||
function DriverPageRoot({ oldDesktop = false }) {
|
function DriverPageRoot({ oldDesktop = false }) {
|
||||||
@@ -92,7 +91,6 @@ function DriverPageContent({ layout, oldDesktop }) {
|
|||||||
return (
|
return (
|
||||||
<ControlSystemProvider>
|
<ControlSystemProvider>
|
||||||
<RoomAudioPlayer />
|
<RoomAudioPlayer />
|
||||||
<UndockedPageExitGuard />
|
|
||||||
<KeyboardInputManager />
|
<KeyboardInputManager />
|
||||||
<GamepadInputManager />
|
<GamepadInputManager />
|
||||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ function CommunityColumn() {
|
|||||||
<div className="h-96 min-h-56">
|
<div className="h-96 min-h-56">
|
||||||
<ChatPanel fillHeight />
|
<ChatPanel fillHeight />
|
||||||
</div>
|
</div>
|
||||||
<OverseerPreferencePanel />
|
|
||||||
<ReplaySourcesPanel panelId="newdrive-replay-sources" />
|
<ReplaySourcesPanel panelId="newdrive-replay-sources" />
|
||||||
<RoverQueuesPanel title="Rovers" />
|
<RoverQueuesPanel title="Rovers" />
|
||||||
<LinkButtonsPanel fillHeight={false} />
|
<LinkButtonsPanel fillHeight={false} />
|
||||||
<PtzQueueCard layout="desktop" />
|
<PtzQueueCard layout="desktop" />
|
||||||
<RawUserPilePanel compact hideNicknameForm />
|
<RawUserPilePanel compact hideNicknameForm />
|
||||||
|
<OverseerPreferencePanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,8 @@ export function buildExternalRoverUrl(instance, roverId, { includeSettings = fal
|
|||||||
export function openExternalRover(instance, roverId, { includeSettings = true } = {}) {
|
export function openExternalRover(instance, roverId, { includeSettings = true } = {}) {
|
||||||
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
|
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
|
||||||
if (!targetUrl) return;
|
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;
|
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.
|
// 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 { lazy, StrictMode, Suspense } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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'
|
import './index.css'
|
||||||
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
|
// 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.
|
// 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 PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||||
import MicApp from './mic/MicApp.jsx'
|
import MicApp from './mic/MicApp.jsx'
|
||||||
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
||||||
|
import PageExitGuard from './components/PageExitGuard/index.jsx'
|
||||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||||
import AppErrorBoundary from './components/AppErrorBoundary.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(
|
createRoot(document.getElementById('root')).render(
|
||||||
<AppErrorBoundary>
|
<AppErrorBoundary>
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -55,34 +92,7 @@ createRoot(document.getElementById('root')).render(
|
|||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
<DeterrenceChaos />
|
<DeterrenceChaos />
|
||||||
<BrowserRouter>
|
<RouterProvider router={router} />
|
||||||
<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>
|
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</TelemetryProvider>
|
</TelemetryProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user