Compare commits

...
Author SHA1 Message Date
legop3 7bac2fc447 todoing
Container image / image (push) Failing after 50s
2026-10-02 23:07:14 -04:00
legop3 e8a407cd49 revise what counts for idle service 2026-10-02 23:05:39 -04:00
legop3 bee1866a70 fixings 2026-10-01 21:40:36 -04:00
legop3 ddfc889a8c basically moving docking flows to the server instead of being in the web UI 2026-10-01 21:21:39 -04:00
legop3 1ba4ebe5ff add more better options for HA activities and automatically changing them (for google home switch lol) 2026-10-01 02:11:07 -04:00
legop3 607d327e9c slop 2026-09-30 22:17:08 -04:00
legop3 1bdca60648 slop 2026-09-30 21:03:06 -04:00
legop3 ea4af2ebb3 add config option for room camera interval
Container image / image (push) Failing after 1m5s
2026-09-29 22:18:45 -04:00
legop3 b2bbaf2085 slop 2026-09-29 22:14:57 -04:00
legop3 18e7fb8d1b slop
Container image / image (push) Failing after 56s
2026-09-28 23:38:19 -04:00
legop3 ba71106544 slop 2026-09-28 23:22:15 -04:00
legop3 a395656056 slop
Container image / image (push) Failing after 54s
2026-09-28 18:36:29 -04:00
legop3 50c2c1f5db slop 2026-09-28 18:15:37 -04:00
legop3 b59389870e slop 2026-09-28 17:43:39 -04:00
legop3 cd4aee4e74 slop 2026-09-28 17:43:23 -04:00
legop3 9de0bfb1e7 Merge pull request #30 from legop3/FEAT-rover-learner-model
Feat rover learner model
2026-09-28 15:46:40 -04:00
legop3 a157ece816 Merge pull request #29 from legop3/REFACTOR-participation-mode-ptz
Container image / image (push) Failing after 1m1s
Refactor participation mode ptz
2026-09-27 18:18:49 -04:00
legop3 8cb7cc5bdc Merge branch 'main' into REFACTOR-participation-mode-ptz
Container image / image (push) Failing after 55s
2026-09-27 18:11:37 -04:00
legop3 34936ab05c more queue related convergence for the future. 2026-09-27 17:19:56 -04:00
legop3 7231809140 gonna break stuff now
Container image / image (push) Failing after 51s
2026-09-24 18:36:24 -04:00
legop3 8ca714cf1a pause ptz turns when no ones on 2026-09-24 18:06:24 -04:00
legop3 ffd34de56c improving the UI side of ptz... and improving the way turns works with UI.... 2026-09-24 17:54:46 -04:00
legop3 846c9dae55 new internal concept: operating mode. will allow for more UI and separate pages to be made to operate things other than rovers. Converted PTZ to new system. 2026-09-22 14:42:21 -04:00
172 changed files with 8599 additions and 4672 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""CPU benchmark of published NoMaD exploration, without ROS or rover connections.
Run: python3 perf/nomad-benchmark.py --setup
Subsequent runs: python3 perf/nomad-benchmark.py
Quick run: python3 perf/nomad-benchmark.py --threads 4 --samples 1 --seconds 2 --iterations 2
--setup installs CPU dependencies in a private cached venv and downloads pinned
upstream source plus the official checkpoint. Nothing touches server packages.
Synthetic images measure compute, NOT navigation quality. Excludes capture,
video decode, network, physical control, training, and concurrent rover load.
"""
import argparse
import hashlib
import importlib.metadata
import json
import math
import os
from pathlib import Path
import platform
import resource
import statistics
import subprocess
import sys
import time
import urllib.request
import venv
VINT = 'dca79815b704e5aa9c6bdc3082351f9e3b2848c2'
DIFFUSION = '5ba07ac6661db573af695b419a7947ecb704690f'
CHECKPOINT_ID = '1YJhkkMJAYOiKNyCaelbS_alpUpAJsOUb'
SOURCES = {
('robodhruv/visualnav-transformer', VINT): [
'train/config/nomad.yaml', 'train/vint_train/data/data_config.yaml',
'train/vint_train/models/nomad/nomad.py',
'train/vint_train/models/nomad/nomad_vint.py',
'train/vint_train/models/vint/self_attention.py',
],
('real-stanford/diffusion_policy', DIFFUSION): [
'diffusion_policy/model/diffusion/conditional_unet1d.py',
'diffusion_policy/model/diffusion/conv1d_components.py',
'diffusion_policy/model/diffusion/positional_embedding.py',
],
}
def positive(value):
result = int(value)
if result < 1:
raise argparse.ArgumentTypeError('Must be positive')
return result
def arguments():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('--setup', action='store_true')
p.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-nomad-benchmark')
p.add_argument('--threads', type=positive, nargs='+', default=[4, 8, 16])
p.add_argument('--samples', type=positive, nargs='+', default=[1, 8], help='Candidate trajectories per decision, not rover count')
p.add_argument('--seconds', type=positive, default=10)
p.add_argument('--iterations', type=positive, default=10)
p.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
p.add_argument('--result', type=Path, help=argparse.SUPPRESS)
return p.parse_args()
def download(url, path):
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + '.part')
print('Downloading', path.name, flush=True)
with urllib.request.urlopen(url, timeout=120) as response, temporary.open('wb') as output:
import shutil
shutil.copyfileobj(response, output)
temporary.replace(path)
def prepare(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', 'torch', 'torchvision',
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
subprocess.run([str(python), '-m', 'pip', 'install', 'numpy', 'diffusers',
'efficientnet-pytorch', 'einops', 'PyYAML', 'gdown'], check=True)
for (repo, revision), paths in SOURCES.items():
for path in paths:
download(f'https://raw.githubusercontent.com/{repo}/{revision}/{path}', args.cache / 'source' / revision / path)
checkpoint = args.cache / 'nomad.pth'
if not checkpoint.exists():
temporary = checkpoint.with_suffix('.part')
subprocess.run([str(python), '-m', 'gdown', CHECKPOINT_ID, '-O', str(temporary)], check=True)
temporary.replace(checkpoint)
def worker(args):
import numpy as np
import torch
import yaml
from diffusers.schedulers.scheduling_ddpm import DDPMScheduler
sys.path[:0] = [str(args.cache / 'source' / VINT / 'train'), str(args.cache / 'source' / DIFFUSION)]
from vint_train.models.nomad.nomad import NoMaD, DenseNetwork
from vint_train.models.nomad.nomad_vint import NoMaD_ViNT, replace_bn_with_gn
from diffusion_policy.model.diffusion.conditional_unet1d import ConditionalUnet1D
threads, samples = args.threads[0], args.samples[0]
torch.set_num_threads(threads)
torch.set_num_interop_threads(1)
torch.manual_seed(0)
c = yaml.safe_load((args.cache / 'source' / VINT / 'train/config/nomad.yaml').read_text())
vision = replace_bn_with_gn(NoMaD_ViNT(
context_size=c['context_size'], obs_encoding_size=c['encoding_size'],
mha_num_attention_heads=c['mha_num_attention_heads'],
mha_num_attention_layers=c['mha_num_attention_layers'], mha_ff_dim_factor=c['mha_ff_dim_factor']))
model = NoMaD(vision, ConditionalUnet1D(input_dim=2, global_cond_dim=c['encoding_size'],
down_dims=c['down_dims'], cond_predict_scale=c['cond_predict_scale']), DenseNetwork(c['encoding_size'])).eval()
# Strict loading prevents accidentally benchmarking a partly random model.
model.load_state_dict(torch.load(args.cache / 'nomad.pth', map_location='cpu', weights_only=True), strict=True)
scheduler = DDPMScheduler(num_train_timesteps=c['num_diffusion_iters'],
beta_schedule='squaredcos_cap_v2', clip_sample=True, prediction_type='epsilon')
width, height = c['image_size']
frames = torch.rand(c['context_size'] + 1, 3, height, width)
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
mask = torch.ones(1, dtype=torch.long)
stats = yaml.safe_load((args.cache / 'source' / VINT / 'train/vint_train/data/data_config.yaml').read_text())['action_stats']
low, high = np.array(stats['min']), np.array(stats['max'])
def decision():
start = time.perf_counter()
obs = ((frames - mean) / std).reshape(1, -1, height, width)
goal = torch.randn(1, 3, height, width)
cond = model('vision_encoder', obs_img=obs, goal_img=goal, input_goal_mask=mask).repeat(samples, 1)
encoded = time.perf_counter()
action = torch.randn(samples, c['len_traj_pred'], 2)
scheduler.set_timesteps(c['num_diffusion_iters'])
for step in scheduler.timesteps:
noise = model('noise_pred_net', sample=action, timestep=step, global_cond=cond)
action = scheduler.step(noise, step, action).prev_sample
# Match upstream get_action: unnormalize deltas then accumulate waypoints.
waypoints = np.cumsum((action.numpy() + 1) / 2 * (high - low) + low, axis=1)
if not np.isfinite(waypoints).all():
raise RuntimeError('Non-finite model output')
end = time.perf_counter()
return (end - start) * 1000, (encoded - start) * 1000, (end - encoded) * 1000
rows = []
with torch.inference_mode():
for _ in range(3):
decision()
start, cpu = time.perf_counter(), time.process_time()
while len(rows) < args.iterations or time.perf_counter() - start < args.seconds:
rows.append(decision())
elapsed, cpu = time.perf_counter() - start, time.process_time() - cpu
total = sorted(r[0] for r in rows)
result = dict(threads=threads, candidateTrajectories=samples, iterations=len(rows),
p50Ms=statistics.median(total), p95Ms=total[math.ceil(.95 * len(total)) - 1], maxMs=max(total),
decisionsPerSecond=len(rows)/elapsed, cpuCores=cpu/elapsed,
peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
visionMeanMs=statistics.mean(r[1] for r in rows), diffusionAndDecodeMeanMs=statistics.mean(r[2] for r in rows),
parameters=sum(p.numel() for p in model.parameters()), diffusionSteps=c['num_diffusion_iters'],
imageSize=c['image_size'], frames=c['context_size']+1,
versions={name: importlib.metadata.version(name) for name in ['torch','torchvision','diffusers','numpy','efficientnet-pytorch','einops']})
args.result.write_text(json.dumps(result))
def main():
args = arguments()
args.cache = args.cache.expanduser().resolve()
if args.worker:
worker(args)
return
if args.setup:
prepare(args)
python = args.cache / 'venv/bin/python'
if not python.exists() or not (args.cache / 'nomad.pth').exists():
raise RuntimeError('First run: python3 perf/nomad-benchmark.py --setup')
print('CPU FP32 NoMaD exploration; synthetic images; no driving-quality measurement.', flush=True)
print('Candidates are trajectories for ONE rover. Includes vision + all 10 diffusion steps.', flush=True)
print('threads candidates p50 ms p95 ms max ms decisions/s CPU cores peak MiB', flush=True)
results = []
import tempfile
with tempfile.TemporaryDirectory(prefix='nomad-bench-') as directory:
for threads in args.threads:
for samples in args.samples:
path = Path(directory) / 'result.json'
subprocess.run([str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
'--threads', str(threads), '--samples', str(samples), '--seconds', str(args.seconds),
'--iterations', str(args.iterations), '--result', str(path)], check=True,
timeout=max(600, args.seconds*20), env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
r = json.loads(path.read_text()); results.append(r)
print(f"{threads:7} {samples:10} {r['p50Ms']:7.1f} {r['p95Ms']:7.1f} {r['maxMs']:7.1f} {r['decisionsPerSecond']:11.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
with (args.cache / 'nomad.pth').open('rb') as f:
digest = hashlib.file_digest(f, 'sha256').hexdigest()
report = dict(benchmark='nomad-cpu-v1', platform=platform.platform(), logicalCpus=os.cpu_count(),
loadAverage=os.getloadavg(), sourceRevisions=dict(vint=VINT,diffusion=DIFFUSION), checkpointSha256=digest,
exclusions=['capture','video decode','network','physical control','training','concurrent model workloads'], results=results)
print('\nPaste this report back:\n' + json.dumps(report, separators=(',', ':')))
print('At 10 decisions/s the total budget is 100ms; at 5/s it is 200ms. Leave room for the excluded pipeline.')
if __name__ == '__main__':
try:
main()
except (Exception, KeyboardInterrupt) as error:
print(f'Benchmark failed: {error}', file=sys.stderr)
sys.exit(1)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
const (
autoChargeTimeout = 1 * time.Second
autoChargeTimeout = 10 * time.Second
autoChargeCooldown = 0 * time.Minute
sourceHomeBase = 1 << 1
)
+304 -15
View File
@@ -2,18 +2,34 @@
## Resume here
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and
admin-created permanent snapshots replace automatic permanent publications.
Latest repair: full learning-path audit. Fixed missing command labels between
video samples with a shared causal 100ms grid; added real-data score/baseline/
training-probe diagnostics and normalized saved control profiles for retained
recordings. Continues policy 5 / lineage v6; this audit does not reset progress.
See the final audit results section for measured synthetic evidence and limits.
Status: policy format 2 now predicts continuous wheel speeds plus accessory events.
Copyable diagnostics and main-card wheel/action displays with a session threshold
slider are implemented. Older model files remain stored but cannot run in this format.
Recording, bounded dataset/training, permanent named models, live inference,
checked autonomous command submission, and the verified VIP card are coded.
Human commands do not stop or pause autonomy (explicit latest user requirement).
Focused local checks passed; actual recording/training, live autonomous behavior,
concurrent server performance, and browser appearance remain unverified.
The standalone CPU benchmark was run successfully on the actual server.
Latest scope (authorized IPI): only drive, camera tilt, headlight and the four
operating-mode/dock commands (128/131/132/143). Exclude horn, song, laser and all
brush/vacuum motor commands from both learning and execution, including raw OI
bypasses. Raw recordings stay intact. Policy format 5, training lineage v6;
startup removes earlier models and lineages through v5. Earlier scope below is
historical. No configuration change required.
Latest change (2026-09-29 IPI): format 4 seeds known user-control command shapes
before training. Generic peripherals are excluded; sensorStream is ignored without
invalidating footage. Drive includes zero-speed drive, with no separate stop type.
Drive, camera, brushes/vacuum, headlight, laser, horn, songs and operating modes
remain supported. Raw recordings and the existing checked command pipe stay intact.
Held-out data cannot expand the schema. Unknown control variants exclude nearby
windows only, with command/count/reason diagnostics and a coverage gate that blocks
promotion. Supported portions still receive evaluation. Current training lineage
is resume-v5.pt / schedule-v5.json. Startup removes obsolete format 1-3 models and
old lineages through v4; recordings and current-format models are preserved.
Verification for this increment is recorded at the end. Earlier version-specific
notes are historical. Manual inputs never pause/cancel autonomy; disabled service
remains dormant. Actual autonomous performance has not been established.
This document preserves the conversation's decisions across compactions and
usage cutoffs. Keep the checklist and handoff section current during authorized
@@ -30,9 +46,8 @@ The user declined the manual server checklist and explicitly asked to continue.
Do not block further work or repeatedly ask them to do it. Preserve the unverified
status and have the reader reject unusable recordings automatically.
Latest increment: internal card gating, dock lead-in/tail capture, live prediction
diagnostics, ongoing retained-data sampling, and gated model publication are coded.
See the final handoff section for settings and the new training lineage.
Latest increment: see the command-event policy handoff at the end. Earlier
format 2 wheel-specific design notes below are historical and superseded.
Next work: verify the integrated feature against actual recordings and the real
camera/rover pipeline when that environment is available; inspect the VIP card in
@@ -115,7 +130,7 @@ the default development path. Intended layout:
rover-learning/
recordings/ Rolling video/events/session metadata (implemented)
models/ Permanent UUID directories: weights.pt and model.json
training/ resume-v3.pt, schedule-v3.json, and current job.json
training/ resume-v4.pt, schedule-v4.json, and current job.json
```
Training/model directories are created only when service and training are enabled.
@@ -786,3 +801,277 @@ no new training reset or configuration migration is required.
- Focused Node syntax and Python worker entry-point checks passed. Targeted card
lint is run for the UI change. End-to-end training/promotion, snapshot copying,
active-session pinning and browser behavior remain unverified without live data.
### UI cleanup and obsolete models
User explicitly requested removal of UI narration and deletion of obsolete models.
Removed snapshot/threshold explanatory prose and the old commented manual-control
note; idle status now says Stopped. Kept operational status/errors and diagnostics.
Enabled-service startup now deletes permanent model directories whose validated
manifest explicitly identifies policy format 1, even when training is disabled.
Format 2 snapshots, Latest/Previous, recordings and current training state remain.
This supersedes earlier promises to preserve incompatible format 1 model files.
Deletion occurs on the actual server when the updated service starts; no production
files were accessed from this development machine.
### Recording maintenance race and diagnostic error details
Authorized by "fix it and fix that IPI" after a production report showed two
maintenance failures but omitted their error details. Scoped to those two defects;
evaluation/cache changes remain proposals, not part of this repair.
- listRecordings now tolerates ENOENT for directories/files removed between listing
and stat (dock-buffer pruning or atomic metadata rename). Other filesystem
errors still propagate; no broad error suppression or retries were added.
- Diagnostics reportVersion 2 includes actual redacted error messages, rejection
reasons, controller results, event detail, and worker stderr. Runtime catches
retain error name/code/syscall/path/stack, timestamp and operation. Recording
failures retain the same structured details through session finalization.
- A shared redactor removes URLs, credentials, user keys, email addresses and home
usernames while retaining filesystem operation context and stack frames. Error
categories remain supplementary, not replacements for actual failures.
- Node syntax checks passed for affected modules. No actual production recording
stream is available here; the race is fixed by inspection but cannot yet be
confirmed as the cause of the earlier production events. Previously discarded
error details cannot be reconstructed. No new tests or processes were created.
### Deliberate held-out selection
Latest IPI authorizes the scheduler correction only. Randomly shuffled eligible
sessions are now partitioned with exactly the worker's SHA256 first-eight-hex
modulo-five rule. Jobs select up to three training sessions and one held-out
session when both partitions exist; otherwise up to four training sessions.
No worker starts with held-out sessions alone. Existing split assignments, model
weights, and training progress are unchanged. Diagnostics expose timestamped
available/selected partition counts and the reason when evaluation cannot be
scheduled. Selection guarantees a held-out recording, not usable evaluation
windows: decoding/vocabulary/sample checks still apply in the worker.
Node syntax checks and diff whitespace checks passed; actual job selection on
production recordings remains unverified. No new tests or background processes.
### Active implementation: command-event policy (authorized IPI)
Supersedes format 2 continuous wheel output. The model predicts numeric command
scores and payload values; ordinary code constructs commands for the existing
shared command pipe. It does not generate JSON text. Drive, recorded key-release
stops, and accessories all use the threshold. Remove wheel-speed sensors and
previous drive-command inputs, but preserve raw recordings. Exact zero values
remain template constants so a demonstrated stop cannot regress into creeping.
Keep one continuing learner, Latest/Previous, admin snapshots, recording lifecycle,
permissions, and manual inputs that never cancel autonomy. No prescribed human
driving. Start a new format 3 lineage, reuse recordings, show older snapshots as
incompatible. No model-size increase or recording redesign in this change.
Update the upper card to show every command proposal with decoded parameters,
score, threshold marker and dispatch result, including below-threshold proposals.
Update copied diagnostics and evaluation for command events, parameter error,
missed stops and unwanted commands. Evaluate more than one held-out recording;
event validation is not proof of autonomous driving or physical rollout quality.
Progress: implemented; focused checks passed. User's unrelated webui/package.json
edit was preserved. No changes to runtime recording, disabled startup, control
permissions or manual-input override semantics.
Implementation details for resume:
- workers/policy.py VERSION=3, CNN/GRU unchanged in size; two heads for scores and
command parameters. Shared select_slots chooses one winner per command family
(peripheral id/control are separate families). No unconditional drive head.
- Numeric zeros and address fields stay exact template constants. Stop is the
normal recorded driveDirect {left:0,right:0} command. Unknown templates still
reject explicitly at the 64-shape limit. Arrays remain exact demonstrations.
- workers/dataset.py yields five arrays: images, state, event labels, parameters,
available shapes. It includes accepted human drive events, excludes ambiguous
conflicting events in the same 100ms window, and no longer requires an inferred
held wheel-speed target. Raw recorded sensors/commands are unchanged.
- Training reserves separate bounded samples for stop events, other commands and
no-command windows. Evaluation remains uniformly sampled. Scheduler selects
up to three training and two held-out recordings under the unchanged SHA split.
- Gate: complete evaluation, >=2 usable held-out sessions, >=128 windows, >=32
command events, >=8 stops, >=16 movement events; command precision/recall, stop
recall and movement recall each >=0.5; normalized numeric MSE <=0.1 where
applicable. Diagnostics expose each failing reason and per-command counts.
These are experimental screening thresholds, not a guarantee of useful driving.
- New optimizer/scheduler filenames prevent silently reusing format 2 weights or
old rejected-session bookkeeping. No config values need changing. Existing
permanent format 2 snapshots are preserved, but cannot start; attempting to
snapshot an old candidate reports that a new command-policy checkpoint is needed.
- Live inference keeps existing discrete toggle latching and common command limits.
The threshold applies to drive and stop just like the other command predictions.
No-event means send nothing: it DOES NOT implicitly stop an already moving rover.
It must learn explicit stop events; removing the copy shortcut is not proof that
learning will succeed. Other motion-related sensors still remain as observations.
- Upper card replaces separate wheel meters with generic scored proposals and
decoded parameters; shows applied threshold and post-clamp submitted parameters.
Copied diagnostics retain top eight proposals and up to 16 submissions per
sampled controller-history entry, including their command objects.
Verification: actual train.py --check-dependencies loaded Torch/NumPy/PyAV and
constructed format 3 (803,440 parameters); infer.py --help loaded successfully;
Node --check passed for six changed service modules; targeted card ESLint passed.
No new tests or throwaway programs. Live end-to-end training/inference and browser
visual verification require the actual environment and have not been performed.
Deferred explicitly: chronological commanded-motion replay/closed-loop evaluation,
prepared frame caching and larger architectures. Current evaluation uses held-out
100ms event windows with the live threshold/family selection, but cannot assess
temporal latching or accumulated motion from random reservoir samples. It does
not claim to verify actual physical command execution. After normal deployment,
let the new learner process retained recordings; copied reportVersion 3 diagnostics
provide the next evidence. Do not demand prescribed driving or a manual checklist.
### Obsolete model cleanup after format 3 transition
Latest user IPI explicitly authorizes deleting obsolete snapshots, checkpoints and
training state. This supersedes earlier format 2 preservation notes above.
Enabled-service startup, before trainer/driving/gateway startup, scans both models/
and training/checkpoints/. Identity-validated manifests with integer policy version
>=1 and < current POLICY_VERSION are removed. Current/future/unknown-format models
and recordings are preserved. Candidate/Latest/Previous references to obsolete
checkpoints are atomically removed before directory deletion; leased directories
are retired until release. Cleanup also removes legacy resume/schedule files and
temporary files for the unversioned and v1-v3 lineages. Current v4 state is kept.
Cleanup runs even when training is disabled, but not when the whole service is off.
Production deletion occurs on the next enabled-service startup after deployment.
Verification: focused Node syntax and diff whitespace checks passed. No production
storage was accessed; actual deletion awaits enabled-service startup on the server.
### User-control scope and evaluation coverage (2026-09-29)
Authorized by latest IPI after user clarified that controls include camera, brushes,
lights, laser, horn and songs but NOT generic peripherals. No new command pipe or
separate stop command. sensorStream is neither input/target nor a boundary. Actual
reset/reboot/update remain temporal boundaries because they interrupt observations.
Format 4 seeds drive and motor zero/nonzero field combinations, servo, light/laser
actions, the two four-frequency horn waveforms and horn release, OI start/safe/full/
dock bytes and default song. Seed values describe shapes, not artificial training
examples. Horn frequencies are numeric parameters; integral JSON numbers share
shapes with equivalent floating representations for drive/motors/servo/horn. Songs
remain exact demonstrated note arrays; other song/raw variants can extend the
training schema within its existing 64-slot bound. No promise to pre-enumerate all
possible tunes. Seeded slots receive negative examples even in sessions where that
control was not used, avoiding untrained random seeded outputs.
Evaluation freezes the training schema. Unsupported commands are reported with
payload, reason, occurrence count (up to 32 distinct descriptions), total count and
affected-window count. The affected observation history/target interval is excluded
without labeling the unknown command as inaction or skipping the entire recording.
Any unsupported held-out command blocks promotion; metrics describe only supported
windows. If none survive, dataset diagnostics still report the coverage problem.
Existing permission checks, threshold mechanics, recording and manual input behavior
are unchanged. Generic peripherals are also removed from execution allowlist and
rover compatibility profile. Previous policy models are automatically cleaned up;
no configuration edit is required.
Verification completed: train.py --check-dependencies loaded the production
worker and constructed format 4 with 803,440 parameters and predefined command
shapes. infer.py --help, Node syntax checks for the four changed service modules,
focused VipRoverLearningCard ESLint and diff whitespace checks passed.
No live server data/hardware/browser available locally. Actual training, driving,
and browser appearance remain unverified. No new tests or background processes.
Deploying starts a new learner using existing recordings; enabled-service startup
removes older policy models and optimizer/scheduler lineages through v4. Current
lineage is v5. No settings changes or prescribed human driving are required.
### Simplified controls, policy 5
User authorized removal of horn/song/laser/brush motors. Retained 13 seeded shapes:
four drive zero/nonzero combinations, two camera angle shapes, three headlight
actions, four raw operating-mode/dock constants. Generic peripherals remain excluded.
Removed excluded command history/targets via policy allowlist and execution via
Node allowlist; raw payloads must exactly match one of the four mode commands at
both boundaries. Controller shutdown now sends only zero-speed drive, leaving
manual brush/horn state alone. Raw recording and sensor telemetry unchanged.
Old policies are deleted on enabled startup; resume-v6.pt/schedule-v6.json begin a
fresh learner using retained recordings. Existing evaluation behavior unchanged.
Verification passed: production train.py --check-dependencies (policy 5, 13 seeded
shapes), infer.py --help, changed Node module syntax, focused card ESLint, and diff
whitespace checks. No live hardware/data available locally; actual learning and
driving quality remain unverified. No tests added or background processes left.
### Active full learning-path audit (authorized IPI)
User explicitly permits local training/inference with controlled synthetic input;
there are NO real recordings on this machine. Existing user-control simplification
edits were present at start and must be preserved. No new schema reset is planned.
Reproduced: production reader with a generated lossless 25 FPS video and 31 valid
human drive events labeled ZERO commands. Old sampler used 120ms frame intervals
with only 100ms target coverage. Fixed causal 100ms grid shared by dataset/inference
uses latest past image; no future pixels. The repaired fixture preserves all 31
commands. Sensor cutoff is strictly before decision in both paths.
Original objective experiment: 12 easy colored-frame samples, 120 steps, seed 7,
4 threads: loss 0.8206 -> 0.1996, mean true-command score 0.5106 -> 0.7268, recall
at 0.7 reached 2/3; at 80 steps recall was still zero. This disproves a wholly
broken gradient path; it does not diagnose real-data score suppression. Keep
existing loss/threshold until real-data diagnostics justify changing them.
New learning.py shares the actual objective for ongoing training and disposable
probe. Per-job before/after evaluation on <=96 unchanged training examples, full
held-out score distributions, threshold sweep, training-only frequency and no-op
baselines, decoded pre-server-limit parameter error, gradient norms and sampled
versus natural frequencies. Bounded <=20s/80-step memorization probe on <=12
training examples at first job/every 1000 steps; copy only, never published or
used for generalization gate. Continues current checkpoint/optimizer lineage.
Audit results (synthetic only):
- Round-tripped eight representative retained command payloads, including signed
drive/servo values, zero-speed drive, headlight and all four mode commands.
- Lossless 25 FPS recording with 31 command events: old sampler labeled 0; new
sampler labeled 31/31, including interleaved ignored sensorStream events.
- Full production train.py decoded five generated recordings, trained/exported
checkpoints, evaluated held-out sessions, and resumed optimizer/weights for a
second job. 120 -> 240 cumulative steps improved recall at unchanged 0.7 from
0 to 0.2553, with precision 1.0 at step 240. Mean true-event score rose 0.5346
-> 0.6303. Gate correctly remained failed. A lower 0.5 threshold had precision
0.6629/recall 0.8369 and 120 false-positive actions, so threshold was NOT lowered.
- Disposable diverse probe covered zero-speed drive, moving drive and servo;
reached recall 0.75 after 39 steps under its 20s budget. Sampling was corrected
to avoid probing only the first event category. Probe uses a copy and higher
learning rate for memorization; never adopts weights or changes main optimizer.
- Exported trained checkpoint reload, forward inference and decoding passed.
- Retained recordings had extra removed actuator fields, causing false model
incompatibility. Normalize saved and current profiles through controlProfile.
Direct Node exercise accepts removed laser/peripheral differences and still
rejects a changed retained camera configuration.
Diagnostics now report accepted vs represented command counts, request/dispatch
delay, maximum sensor/image age; sampled vs natural command frequencies; gradient
norms; same training-subset before/after scores; per-command score distributions;
threshold sweep and no-op/training-frequency baselines; pre-server-limit decoded
parameter errors; and bounded diverse memorization probe. Evaluation-step identity
separates last evaluation from current-job progress. Compact history avoids
repeating entire command/score tables for every job. Four concise numeric rows
were added to expanded details; full evidence is in Copy diagnostics.
The original loss and configured 0.7 threshold remain unchanged because experiments
did not establish that a different objective/threshold fixes real recordings.
Promotion keeps existing checks and additionally must beat the simple training-
frequency baseline F1. One-time retry of old reader-specific no-aligned-window
rejections preserves optimizer state and unrelated errors.
Limits: actual human footage and rover/RTSP transport unavailable. Isolated held-out
windows cannot measure sequential latch behavior or physical driving; decoded
parameter errors are explicitly before Node's rover limits/permission/safety
handling. Main model learned some synthetic cues but has not demonstrated useful
autonomous driving. Camera command encoding still uses whole-degree predictions.
The user must not be asked to drive prescribed routes or perform a manual checklist.
Verification: full synthetic worker jobs and checkpoint inference above passed;
focused Node syntax/card ESLint/diff checks passed before final report additions.
One experimental seeded run failed because its runpy launcher omitted worker
import path; corrected launcher succeeded. No production import failure occurred.
Synthetic inference-worker media exercise passed: eight prediction batches,
threshold update to 0.3, retained command types and discrete latching exercised.
This check used a generated checkpoint and a FIFO media adapter preserving
synthetic PTS (RTSP wall-clock override disabled in the experiment only); it does
not validate real network capture timing. Trained checkpoint forward/decode was
verified separately. Temporary media launchers needed fixes for file lifetime
and command-string escaping; these were experiment issues, not production fixes.
No repository tests added. Experiments finished and no background worker remains.
Final focused Node syntax and diff whitespace checks passed. Card lint passed.
No additional schema/optimizer reset introduced by this audit.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
import{r as g,R as a}from"./index-5EWtcenj.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
//# sourceMappingURL=Preview-CKTJqKe-.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-DN7XBkOe.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
import{R as W}from"./index-5EWtcenj.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
<style>
:host {
display: inline-block;
line-height: 0;
}
video,
audio {
max-width: 100%;
max-height: 100%;
min-width: 100%;
min-height: 100%;
}
</style>
<slot></slot>
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
<div class="wistia_embed"></div>
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
<style>
:host {
display: inline-block;
min-width: 300px;
min-height: 150px;
position: relative;
}
::slotted(.wistia_embed) {
position: absolute;
width: 100%;
height: 100%;
}
</style>
<slot></slot>
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
//# sourceMappingURL=react-DxdYv-AY.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import{_ as N,R as A}from"./index-5EWtcenj.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
`,i+=`${e.text}
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
//# sourceMappingURL=react-gdx3v4jU.js.map
File diff suppressed because one or more lines are too long
+12 -2
View File
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { getOperatingMode } = require('../operatingModeService');
const { compareRoversForAssignment } = require('./roverRanking');
const socketRefs = new Map(); // socketId -> socket
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
});
function assignSocket(socket, options = {}) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
return;
}
// avoid double assignment
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
module.exports = {
assignmentEvents,
resumeAssignment: assignSocket,
releaseForOperatingMode: (socket) => {
// Include direct admin control as well as automatic assignment ownership.
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
const assigned = assignments.get(socket.id);
if (assigned) roverIds.add(assigned);
waiting.delete(socket.id);
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
},
describeAssignment,
forceRelease,
forceReleaseWithNotice,
rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return;
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
const previous = assignments.get(socket.id);
if (previous && previous !== roverId && releasePrevious) {
roverManager.releaseControl(previous, socket);
@@ -2,20 +2,14 @@
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
const { v4: uuidv4 } = require('uuid');
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { getRole } = require('../roleService');
const { getOperatingMode } = require('../operatingModeService');
const { describeAssignment } = require('../assignmentService');
const { getNickname } = require('../nicknameService');
const ptzCameraService = require('../ptzCameraService');
function resolvePtzChatTarget(socketId) {
return ptzCameraService.getChatTargetForSocket(socketId) || null;
}
function resolveRoverId(socketId) {
const ptzTarget = resolvePtzChatTarget(socketId);
if (ptzTarget?.roverId) return ptzTarget.roverId;
const primary = roverManager.getPrimaryRoverForSocket(socketId);
if (primary) return primary;
const assignment = describeAssignment(socketId);
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
function resolveRoverColor(roverId) {
if (!roverId) return null;
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
return ptzCameraService.getPublicState()?.color || null;
}
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function resolveRoverName(roverId) {
if (!roverId) return null;
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
return ptzCameraService.getPublicState()?.name || null;
}
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.name || null;
}
function isPtzChatTargetId(roverId) {
/*
PTZ is intentionally treated as a virtual rover for chat identity only. It
does not live in roverManager.rovers because movement, video authorization,
and queue ownership are PTZ-service concerns, but chat needs one stable
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
code can all render the same badge without learning PTZ internals.
*/
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
}
function isPublicChatTargetId(roverId, socket = null) {
if (!roverId) return false;
/*
Normal rovers remain governed by the existing replay visibility rule, which
is also the rule chat historically used to avoid exposing closed private
rover activity. PTZ gets an explicit allow-list entry here because it is a
public chat target that deliberately pretends to be a rover, even though it
is not a roverManager record.
*/
if (isPtzChatTargetId(roverId)) return true;
return roverManager.canReplayRoverId(roverId, socket) === true;
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
}
function isPrivateClosedRoverId(roverId) {
if (!roverId) return false;
/*
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
but it is not a private rover. Let PTZ-badged messages broadcast normally
instead of falling into the closed-private rover path for unknown ids.
*/
return !isPublicChatTargetId(roverId);
return Boolean(roverId) && !isPublicChatTargetId(roverId);
}
function normalizeProfileImageUrl(value) {
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
socketId: socket?.id || null,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
operatingMode: socket ? getOperatingMode(socket) : null,
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
roverId,
roverName,
roverColor,
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
socketId,
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
operatingMode: socket ? getOperatingMode(socket) : null,
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
roverId,
roverName,
roverColor,
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
module.exports = {
resolveRoverId,
isPtzChatTargetId,
isPublicChatTargetId,
isPrivateClosedRoverId,
buildRoverCtxSnapshot,
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
function playTypingNote(roverId, note, socketId) {
if (!roverId) return;
/*
PTZ borrows the roverId field for chat badges, but it has no rover command
channel. Skipping the song command here keeps PTZ chat from producing noisy
"unknown rover" command attempts while still allowing the message itself to
behave like rover chat everywhere else.
*/
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
try {
issueCommand(roverId, {
type: 'song',
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
if (!ttsOptions || !message) return;
if (message.operatingMode === 'ptz') {
/*
PTZ has no rover websocket, but it does have a real speaker behind the
Reolink/neolink path. Keep PTZ routing here so chat remains the single
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
});
return;
}
if (!message.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
if (!ttsEnabled) return;
+38 -3
View File
@@ -85,7 +85,7 @@ function normalizeOutboundCommandPayload(payload = {}) {
};
}
function issueCommand(roverId, payload) {
function issueCommand(roverId, payload, options = {}) {
const record = roverManager.rovers.get(roverId);
if (!record || !record.ws) {
throw new Error('Rover offline');
@@ -93,6 +93,11 @@ function issueCommand(roverId, payload) {
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
throw new Error('Laser disabled while room lights are locked on');
}
if (options.dockingOwned && payload.driveDirect && getCommandMotionMagnitude('drive', payload) > 0
&& Date.now() < (driveCooldowns.get(roverId) || 0)) {
throw new Error('Drive blocked: safety cooldown');
}
payload = require('../dockingService').protectCommand(record, payload, options);
const id = uuidv4();
const normalizedPayload = normalizeOutboundCommandPayload(payload);
const message = { ...normalizedPayload, id };
@@ -124,7 +129,14 @@ overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
*/
return false;
}
issueCommand(roverId, payload);
const record = roverManager.rovers.get(roverId);
const reverseAdjustment = payload.driveDirect
&& (payload.driveDirect.left < 0 || payload.driveDirect.right < 0)
&& payload.driveDirect.left <= 0 && payload.driveDirect.right <= 0
&& require('../dockingService').canAdjustReverse(record);
// Only protection's adjustments to the active reverse inherit controller
// ownership. Zero remains an ordinary stop and cancels the sequence.
issueCommand(roverId, payload, { dockingOwned: Boolean(reverseAdjustment) });
return true;
});
@@ -315,6 +327,22 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
const record = roverManager.rovers.get(roverId);
if (!record?.ws) throw new Error('Rover offline');
const docking = require('../dockingService');
// The controller owns wheel output during undocking. Discard all ordinary
// drive input, including neutral, before it can alter protection's intent.
if (docking.shouldIgnoreDriveInput(record, payload)) {
reply({ ignored: true, reason: 'undocking' });
return;
}
// Reject before recording held motor intent: otherwise a rejected input
// could be replayed by protection as soon as the docking lock is released.
if (!docking.intents.has(type)) {
const guarded = docking.protectCommand(record, { ...payload, type });
type = guarded.type;
payload = guarded;
}
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect) {
if (!isAdminSocket) {
@@ -361,7 +389,14 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
bypassed: isAdminSocket,
});
}
const id = issueCommand(roverId, { type, ...payload });
const isDockingIntent = docking.intents.has(type);
const id = isDockingIntent
? docking.issueIntent(roverManager.rovers.get(roverId), type, payload, socket)
: issueCommand(roverId, { ...payload, type });
if (isDockingIntent) {
// The server acknowledges accepting the intent; telemetry reports its completion.
io.emit('commandAck', { roverId, id, status: 'ok' });
}
commandEvents.emit('observation', {
ts: Date.now(),
roverId: String(roverId),
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
function handleChatBridgeOutbound(event) {
const payload = event?.payload;
if (!payload) return;
/*
Outbound bridge filtering must use chat visibility, not rover replay
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
chat badge path can be reused, but that id is not a roverManager rover and
would be dropped by canReplayRoverId().
*/
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
const guildConfigs = listGuildConfigs();
if (!guildConfigs.length) return;
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
}
/*
The chat payload already carries the resolved display name for rover-like
targets. Prefer that name so PTZ, which is intentionally pretending to be a
rover in chat, shows up as "PTZ Camera" instead of the internal id
"ptz-camera"; fall back to the id for older payloads or missing metadata.
*/
const roverTagLabel = payload.roverName || payload.roverId;
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
}
+302
View File
@@ -0,0 +1,302 @@
// Server-owned docking operations.
const { v4: uuidv4 } = require('uuid');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('dockingService');
const states = new WeakMap();
const intents = new Set(['enterDocking', 'cancelDocking', 'undock']);
const ASSIST_SPEED = 60;
const SENSOR_TIMEOUT_MS = 1500;
const UNDOCK_TIMEOUT_MS = 4000;
const REVERSE_TIMEOUT_MS = 1000;
// Preserve the working OI preparation sequence; only backing away is sensor-driven.
const MODE_COMMAND_DELAY_MS = 100;
const FULL_MODE_SETTLE_MS = 300;
function stateFor(record) {
if (!states.has(record)) {
states.set(record, {
contact: null, charging: false, latched: false, assist: false,
operation: null, error: null, seenAt: 0, timer: null, contactDockPending: false,
});
}
return states.get(record);
}
function snapshot(record) {
const state = stateFor(record);
return {
phase: state.operation ? 'undocking' : state.contact ? (state.charging ? 'charging' : 'docked') : state.assist ? 'assisting' : 'idle',
movementLocked: Boolean(state.operation || state.latched || state.error || state.contact == null),
cameraLocked: state.assist,
error: state.error,
};
}
function publish(record) {
if (!record.lastSensor?.decoded) return;
record.lastSensor.decoded = { ...record.lastSensor.decoded, docking: snapshot(record) };
// State transitions use the same telemetry event, including failures when the
// physical sensor stream has stalled. Never manufacture a new sensor timestamp.
io.to(record.room).emit('sensorFrame', {
roverId: record.id, frame: record.lastSensor.raw, sensors: record.lastSensor.decoded,
overcurrentProtection: record.lastOvercurrentProtection || null,
});
}
function send(record, payload) {
return require('../commandService').issueCommand(record.id, payload, { dockingOwned: true });
}
function stop(record) {
// Forget the previous held intent so protection cannot replay it after unlock.
require('../overcurrentProtectionService').protectCommand(record.id, 'drive', { driveDirect: { left: 0, right: 0 } });
send(record, { type: 'drive', driveDirect: { left: 0, right: 0 } });
}
function stopAllMotion(record) {
stop(record);
const payload = { motorPwm: { main: 0, side: 0, vacuum: 0 } };
require('../overcurrentProtectionService').protectCommand(record.id, 'motors', payload);
send(record, { type: 'motors', ...payload });
}
function mode(record, opcode) {
send(record, { type: 'raw', raw: Buffer.from([opcode]).toString('base64') });
}
function camera(record, angle) {
if (record.meta?.cameraServo?.enabled) send(record, { type: 'servo', servo: { angle } });
}
function song(record, notes, slot = 0, duration = 10) {
send(record, { type: 'song', song: { slot, notes: notes.map((note) => ({ note, duration })) } });
}
function finishAssist(record, { playExitSong = true } = {}) {
const state = stateFor(record);
if (!state.assist) return;
state.assist = false;
camera(record, 0);
if (playExitSong) song(record, [83, 76]);
}
function clearOperation(state) {
clearTimeout(state.timer);
state.timer = null;
state.operation = null;
}
function fail(record, code, message) {
const state = stateFor(record);
clearOperation(state);
state.error = { code, message };
state.latched = true;
try { stop(record); } catch (error) {
logger.warn('Unable to stop docking operation', { roverId: record.id, error: error.message });
}
publish(record);
}
function armDeadline(record) {
const state = stateFor(record);
clearTimeout(state.timer);
if (!state.operation) return;
const deadline = Math.min(
state.seenAt + SENSOR_TIMEOUT_MS,
state.operation.startedAt + UNDOCK_TIMEOUT_MS,
state.operation.reversingAt ? state.operation.reversingAt + REVERSE_TIMEOUT_MS : Infinity,
);
state.timer = setTimeout(() => {
const stale = Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS;
fail(record, stale ? 'telemetryUnavailable' : 'undockTimeout', stale ? 'Rover telemetry stopped during undocking.' : 'Unable to leave the dock.');
}, Math.max(1, deadline - Date.now()));
state.timer.unref?.();
}
function handleIntent(record, type, payload, socket) {
const state = stateFor(record);
if (type === 'cancelDocking') {
if (state.operation) fail(record, 'undockCancelled', 'Undocking stopped.');
stop(record);
finishAssist(record);
publish(record);
return;
}
state.error = null;
state.contactDockPending = false;
require('../roverManager').stopDockGuard(record.id);
stop(record);
if (type === 'enterDocking') {
state.latched = false;
if (!state.assist) {
state.assist = true;
camera(record, record.meta?.cameraServo?.minAngle ?? -45);
song(record, [76, 83]);
}
} else {
finishAssist(record);
camera(record, 0);
state.operation = {
startedAt: Date.now(), socket, backoff: payload.backoff !== false, reversing: false,
stage: 'start', nextStepAt: Date.now() + MODE_COMMAND_DELAY_MS,
};
mode(record, 128);
armDeadline(record);
}
publish(record);
}
function processTelemetry(record, sensors) {
const state = stateFor(record);
if (!sensors?.chargingSources || !sensors?.oiMode) return snapshot(record);
const previousContact = state.contact;
state.seenAt = Date.now();
state.contact = Boolean(sensors.chargingSources.homeBase);
state.charging = [1, 2, 3, 4].includes(sensors.chargingState?.code);
if (previousContact == null) state.latched = state.contact;
try {
const operation = state.operation;
if (operation) {
const manager = require('../roverManager');
if (!operation.socket?.connected || !manager.canDrive(record.id, operation.socket)) {
fail(record, 'controlLost', 'Undocking stopped because control changed.');
} else if (Date.now() - operation.startedAt >= UNDOCK_TIMEOUT_MS
|| (operation.reversingAt && Date.now() - operation.reversingAt >= REVERSE_TIMEOUT_MS)) {
fail(record, 'undockTimeout', 'Unable to leave the dock.');
} else if (Date.now() < operation.nextStepAt) {
// Advance only on fresh telemetry, after the previous OI command settles.
} else if (operation.stage === 'start') {
mode(record, 143);
operation.stage = 'dock';
operation.nextStepAt = Date.now() + MODE_COMMAND_DELAY_MS;
} else if (operation.stage === 'dock') {
mode(record, 132);
operation.stage = 'full';
operation.nextStepAt = Date.now() + FULL_MODE_SETTLE_MS;
} else if (sensors.oiMode.label === 'full') {
if (!state.contact || !operation.backoff) {
stop(record);
clearOperation(state);
state.latched = state.contact;
} else if (!operation.reversing) {
operation.reversing = true;
operation.reversingAt = Date.now();
const speed = Math.max(1, Math.min(500, Number(record.meta?.maxWheelSpeed) || 500));
// Track the controller's own intent in existing protection so stalls
// still stop it and subsequent adjustments refer to this reverse only.
const drive = require('../overcurrentProtectionService').protectCommand(record.id, 'drive', {
driveDirect: { left: -speed, right: -speed },
}, { bypassed: require('../roleService').isAdmin(operation.socket) });
if (!drive.driveDirect.left && !drive.driveDirect.right) throw new Error('Undocking blocked by wheel protection');
send(record, { ...drive, type: 'drive' });
}
} else if (operation.reversing) {
fail(record, 'modeChanged', 'Undocking stopped because the Roomba mode changed.');
}
armDeadline(record);
} else if (state.contact) {
state.latched = true;
if (previousContact !== true) {
stopAllMotion(record);
state.contactDockPending = true;
if (state.assist) song(record, [84], 1, 6);
} else if (state.contactDockPending && !state.charging) {
const speeds = sensors.wheelSpeedsMmPerSecond;
// A stop request does not prove the wheels stopped on the contacts.
// Require a subsequent frame with contact and measured stationary wheels.
if (speeds?.left === 0 && speeds?.right === 0) {
mode(record, 143);
state.contactDockPending = false;
}
}
if (state.charging) {
state.contactDockPending = false;
// An immediate exit song would replace the contact beep on the Roomba.
finishAssist(record, { playExitSong: false });
}
} else {
state.contactDockPending = false;
if (previousContact === true && state.assist) song(record, [72], 1, 6);
}
} catch (error) {
fail(record, 'commandFailed', error.message);
}
return snapshot(record);
}
function shouldIgnoreDriveInput(record, payload) {
if (!stateFor(record).operation) return false;
if (payload.driveDirect) return true;
const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
return Boolean(raw && [137, 145].includes(raw[0]));
}
function protectCommand(record, payload, { dockingOwned = false } = {}) {
if (dockingOwned) return payload;
const state = stateFor(record);
// Raw OI drive packets must obey the same movement lock. Other raw commands,
// including mode changes, remain available and can interrupt the operation.
const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
if (state.operation && raw && [128, 131, 132, 133, 134, 135, 136, 143].includes(raw[0])) {
// Explicit mode commands remain available and take precedence over an
// automatic sequence, including while it is still waiting for full mode.
fail(record, 'modeChanged', 'Undocking stopped by a Roomba mode command.');
}
if (raw && [137, 145].includes(raw[0])) {
if (raw.length !== 5) throw new Error('Invalid raw drive command');
const first = raw.readInt16BE(1);
const second = raw.readInt16BE(3);
if (raw[0] === 137 && first !== 0 && state.assist && !snapshot(record).movementLocked) {
const limited = Buffer.from(raw);
limited.writeInt16BE(Math.max(-ASSIST_SPEED, Math.min(ASSIST_SPEED, first)), 1);
return { ...payload, raw: limited.toString('base64') };
}
if (raw[0] === 145) {
return protectCommand(record, { type: 'drive', driveDirect: { left: second, right: first } });
}
if (first === 0 && state.operation) fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
if (first !== 0 && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
}
// User drive input is discarded at submitCommand before reaching here.
// Internal safety stops still interrupt the controller through this path.
if (payload.driveDirect) {
const { left, right } = payload.driveDirect;
if (!Number(left) && !Number(right) && state.operation) {
fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
}
if ((Number(left) || Number(right)) && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
if (state.assist) {
const scale = Math.min(1, ASSIST_SPEED / Math.max(Math.abs(left), Math.abs(right), 1));
return { ...payload, driveDirect: { left: Math.round(left * scale), right: Math.round(right * scale) } };
}
}
if (payload.servo && state.assist) throw new Error('Camera locked during dock assist');
return payload;
}
function disconnect(record) {
const state = stateFor(record);
if (state.operation) fail(record, 'roverDisconnected', 'Rover disconnected during undocking.');
clearOperation(state);
state.seenAt = 0;
state.contact = null;
state.assist = false;
}
function canAdjustReverse(record) {
return Boolean(record && stateFor(record).operation?.reversing);
}
function issueIntent(record, type, payload, socket) {
const state = stateFor(record);
// Validate before mutating, so duplicate/rejected requests cannot disturb an
// operation that is already in progress.
if (type !== 'cancelDocking') {
if (state.operation) throw new Error('Undocking is already in progress');
if (Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS || state.contact == null) {
throw new Error('Waiting for current rover telemetry');
}
// Already at the requested destination; acknowledge without restarting assist.
if (type === 'enterDocking' && state.contact) return uuidv4();
}
const id = uuidv4();
try {
handleIntent(record, type, payload, socket);
} catch (error) {
fail(record, 'commandFailed', error.message);
throw error;
}
return id;
}
module.exports = { shouldIgnoreDriveInput, canAdjustReverse, snapshot, intents, issueIntent, processTelemetry, protectCommand, disconnect };
@@ -1,5 +1,4 @@
// User and idle commands share one dispatch contract, while only the trusted
// idle entry point can bypass per-item user locks.
// User and automated commands share validation; only trusted automation bypasses user permissions.
const { buildEntity, buildCommand } = require('./entityHelpers');
function assertAccess(actor = {}) {
@@ -9,13 +8,14 @@ function assertAccess(actor = {}) {
}
function createActions({ getConfig, ha, locks }) {
async function execute(id, value, actor, idle = false) {
if (!idle) assertAccess(actor);
async function execute(id, value, actor, automated = false) {
if (!automated) assertAccess(actor);
const config = getConfig();
if (!config.enabled) throw new Error('Activity Controls are disabled');
const item = config.items.find((entry) => entry.id === id);
if (!item) throw new Error('Unknown activity item');
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
if (!automated && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
if (!automated && item.readOnly && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is read-only for users');
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
if (!entity.available) throw new Error('This item is unavailable');
@@ -25,30 +25,34 @@ function createActions({ getConfig, ha, locks }) {
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
}
async function runIdleActions() {
async function runAutomation(trigger, shouldRun = () => true) {
const config = getConfig();
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
if (!config.enabled) return { action: trigger, skipped: true };
const results = [];
// Catch per item so one offline integration cannot prevent the remaining
// configured cleanup actions from running during this idle window.
// configured actions from running for this trigger.
for (const item of config.items) {
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
// Stop remaining writes if presence or mode changed while HA was responding.
if (!shouldRun()) break;
const setting = item.automations?.[trigger];
if (!setting?.action || setting.action === 'unchanged') continue;
try {
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
if (entity.type === 'readOnly') continue;
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
if ((setting.action === 'press') !== (entity.type === 'button')) throw new Error('Automation action does not match the control type');
// The admin form omits an empty optional string. Treat that as empty
// text so clearing a message works; other types still reject it through
// their normal value validation rather than silently receiving zero.
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
await execute(item.id, setting.action === 'press' ? 'press' : (setting.value ?? ''), null, true);
results.push({ id: item.id, ok: true });
} catch (error) {
results.push({ id: item.id, ok: false, error: error.message });
}
}
return { action: 'homeAssistantActivitiesIdle', results };
return { action: trigger, results };
}
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
return { act: (id, value, actor) => execute(id, value, actor), runAutomation,
runIdleActions: () => runAutomation('idle') };
}
module.exports = { createActions, assertAccess };
@@ -29,7 +29,7 @@ test('sensor states, live constraints, and unknown button states are preserved',
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'toggle');
});
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
@@ -87,22 +87,22 @@ test('permissions, locks, availability and allowlist are authoritative', async (
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
const h = harness([
{ id: 'switch.keep' },
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
{ id: 'number.bad', automations: { idle: { action: 'set', value: '200' } } },
{ id: 'switch.fan', automations: { idle: { action: 'set', value: 'off' } } },
// An empty optional text box is omitted by the schema-driven admin form.
{ id: 'input_text.message', idleAction: 'set' },
{ id: 'input_button.stop', idleAction: 'press' },
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
{ id: 'input_text.message', automations: { idle: { action: 'set' } } },
{ id: 'input_button.stop', automations: { idle: { action: 'press' } } },
{ id: 'sensor.humidity', automations: { idle: { action: 'set', value: '0' } } },
{ id: 'switch.readonly', readOnly: true, automations: { idle: { action: 'set', value: 'off' } } },
], {
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
'switch.readonly': raw('on'), 'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
});
h.locked.add('switch.fan');
const result = await h.actions.runIdleActions();
assert.equal(result.results[0].ok, false);
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
assert.equal(result.results.filter((entry) => entry.ok).length, 4);
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }, { entity_id: 'switch.readonly' }]);
});
test('an outstanding service response does not block later commands', async () => {
@@ -2,6 +2,14 @@
// never acquire unrelated devices simply because they share a connection.
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
// All triggers use the same inputs so the admin form and execution contract agree.
function automation(title, description) {
return strictObject({
action: string({ title: 'Action', enum: ['unchanged', 'set', 'press'], default: 'unchanged' }),
value: string({ title: 'Value', description: 'For set: on/off, a number, exact selection, or text. Empty text clears it. Ignored for press.', maxLength: 255 }),
}, { title, description });
}
module.exports = {
key: 'homeAssistantActivities',
feature: true,
@@ -18,9 +26,12 @@ module.exports = {
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
// Match social-link colors so admins can customize tiles through the existing config form.
color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }),
readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }),
idleAction: string({ title: 'When idle', enum: ['unchanged', 'set', 'press'], default: 'unchanged', description: 'Leave unchanged, set the idle value, or press a button once. Runs independently of user locks.' }),
idleValue: string({ title: 'Idle value', description: 'For set: on/off, a number, exact selection, or text (empty text clears it). Checked against live entity limits when idle runs.', examples: ['0'], maxLength: 255 }),
readOnly: boolean({ title: 'Read only for users', default: false, description: 'Ordinary users can only view this item. Admins and automations can still change supported controls.' }),
automations: strictObject({
idle: automation('When idle', 'Runs after the idle timeout with no users or admins online.'),
locked: automation('When server locked', 'Runs on entering admin or lockdown mode.'),
online: automation('When users online', 'Runs when a user or admin comes online, or the server reopens with one online. Suppressed in admin and lockdown modes. Spectators do not count.'),
}, { title: 'Automations', description: 'One-time actions that bypass item locks and read-only permissions.' }),
}, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }),
},
}, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
@@ -9,7 +9,8 @@ const TYPES = {
function buildEntity(item, raw, locked = false) {
const attributes = raw?.attributes || {};
const domain = item.id.split('.')[0];
const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly';
// Read-only is a user permission, not an entity capability: admins and automation still need its write type.
const type = TYPES[domain] || 'readOnly';
// A never-pressed button legitimately reports unknown. Its state is a last
// press timestamp, not availability or a boolean toggle state.
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
@@ -17,7 +18,7 @@ function buildEntity(item, raw, locked = false) {
return {
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
// Include presentation settings in session state so all clients share the configured color.
icon: item.icon || '', color: item.color || '', domain, type, locked, available,
icon: item.icon || '', color: item.color || '', domain, type, readOnly: Boolean(item.readOnly), locked, available,
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
@@ -8,7 +8,7 @@ const { buildEntity } = require('./entityHelpers');
const { createLocks, resolveItem } = require('./locks');
const { createActions } = require('./actions');
const { getRole } = require('../roleService');
const { getMode } = require('../modeManager');
const { getMode, modeEvents } = require('../modeManager');
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
const events = new EventEmitter();
@@ -24,6 +24,35 @@ const actions = createActions({ getConfig: () => config, ha: {
callHomeAssistantService: ha.callHomeAssistantService,
}, locks });
let operatorsOnline = false;
let lastAutomationState = null;
let automationRevision = 0;
function refreshAutomation() {
const mode = getMode();
const locked = mode === 'admin' || mode === 'lockdown';
// Remember transitions, not entity values: HA updates and extra connections
// must not repeatedly press buttons or undo a manual admin adjustment.
const state = config.enabled && snapshotReady && ha.isConnected()
? (locked ? mode : operatorsOnline ? 'online' : null) : null;
if (state === lastAutomationState) return;
lastAutomationState = state;
const revision = ++automationRevision;
if (!state) return;
actions.runAutomation(locked ? 'locked' : 'online', () => revision === automationRevision)
.then((result) => {
const failures = result.results?.filter((item) => !item.ok) || [];
if (failures.length) logger.warn('Activity automation failed', { trigger: result.action, failures });
}).catch((error) => logger.warn('Activity automation failed', { error: error.message }));
}
function setOperatorsOnline(online) {
operatorsOnline = online;
refreshAutomation();
}
modeEvents.on('change', refreshAutomation);
function getState() {
return {
enabled: config.enabled,
@@ -53,14 +82,17 @@ function setLocked(query, locked) {
ha.homeAssistantEvents.on('snapshot', () => {
snapshotReady = true;
emitUpdate();
refreshAutomation();
});
ha.homeAssistantEvents.on('status', () => {
snapshotReady = false;
emitUpdate();
refreshAutomation();
});
registerConfigurationHandler('homeAssistantActivities', (next) => {
config = next;
emitUpdate();
refreshAutomation();
});
// Resolve access on the server for every command; clients only supply item/value.
io.on('connection', (socket) => {
@@ -76,4 +108,7 @@ io.on('connection', (socket) => {
});
});
module.exports = { ...actions, getState, setLocked, activityEvents: events };
module.exports = { ...actions, getState, setLocked, setOperatorsOnline, activityEvents: events,
// A returning operator ends idle cleanup even if earlier HA calls are still settling.
runIdleActions: () => actions.runAutomation('idle', () => !operatorsOnline),
};
+18 -18
View File
@@ -4,11 +4,15 @@
const logger = require('../../globals/logger').child('idleService');
const io = require('../../globals/io');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { isDeterred, verificationEvents } = require('../verificationService');
const { IDLE_TIMEOUT_MS } = require('./constants');
const { runtime } = require('./state');
const { runIdleActions } = require('./actions');
const activityControls = require('../homeAssistantActivitiesService');
function getActivitySnapshot() {
const mode = getMode();
let onlineUsers = 0;
let onlineAdmins = 0;
let onlineSpectators = 0;
@@ -28,12 +32,16 @@ function getActivitySnapshot() {
return;
}
/*
Lockdown admins are counted with regular admins because both represent a
person with operator-level access who may be supervising the room without
actively driving a rover. Plain users also count even before they request
control, which is the behavior this service now needs.
*/
if (
isDeterred(socket)
|| (mode === MODES.ADMIN && role !== 'admin' && role !== 'lockdown')
|| (mode === MODES.LOCKDOWN && role !== 'lockdown')
) {
onlineIgnored += 1;
return;
}
if (role === 'admin' || role === 'lockdown') {
onlineAdmins += 1;
return;
@@ -89,7 +97,7 @@ function scheduleIdleTimer() {
runtime.deadlineAt = null;
const activity = getActivitySnapshot();
if (activity.totalActive > 0) {
logger.info('Idle automation skipped; user or admin online', activity);
logger.info('Idle automation skipped; eligible operator online', activity);
return;
}
runtime.lastTriggeredAt = Date.now();
@@ -113,16 +121,12 @@ function scheduleIdleTimer() {
function refreshIdleState() {
const activity = getActivitySnapshot();
logger.info('Idle state refresh', activity);
activityControls.setOperatorsOnline(activity.totalActive > 0);
if (activity.totalActive > 0) {
clearIdleTimer();
if (runtime.idleActionsCompleted) {
logger.info('Idle action one-shot reset; operator is online again', activity);
}
/*
A user/admin coming online starts a new activity window. When the room
later becomes idle again, the cleanup pipeline should be allowed to run
once for that new idle period.
*/
runtime.idleActionsCompleted = false;
return;
}
@@ -130,17 +134,13 @@ function refreshIdleState() {
}
io.on('connection', (socket) => {
/*
A user/admin can be online without ever touching rover controls, so socket
presence has to be a first-class idle signal. The disconnect hook is just as
important: it is what starts the idle timeout after the last non-spectator
leaves, even if no driving event happens around that departure.
*/
refreshIdleState();
socket.on('disconnect', refreshIdleState);
});
roleEvents.on('change', refreshIdleState);
modeEvents.on('change', refreshIdleState);
verificationEvents.on('change', refreshIdleState);
refreshIdleState();
@@ -10,7 +10,8 @@ const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('.
const { getConfiguredSocials } = require('../sessionService/configuration');
const { getMode, MODES } = require('../modeManager');
const roverManager = require('../roverManager');
const { getTurnQueues } = require('../turnService');
const { getTurnQueues, getActiveDrivers } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
const { getRoverSnapshotState } = require('../roverSnapshotService');
const { getRole } = require('../roleService');
@@ -157,22 +158,6 @@ function publicRoverIdSet(roster = []) {
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
}
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
const visible = publicIds instanceof Set ? publicIds : new Set();
const next = {};
/*
RoverQueuesPanel creates fallback rows for queue ids that are not present in
the roster, so the public payload must filter queues with the exact same
privacy boundary as the roster. Otherwise a closed-private rover can leak as
an orphan queue row after a private access grant assigns someone to it.
*/
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
if (!visible.has(String(roverId))) return;
next[roverId] = info;
});
return next;
}
function filterPublicUsers(users = [], publicIds) {
const visible = publicIds instanceof Set ? publicIds : new Set();
/*
@@ -203,6 +188,8 @@ function buildLocalInfo() {
const publicIds = publicRoverIdSet(publicRoster);
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
const turnQueues = getTurnQueues();
const activeDrivers = getActiveDrivers();
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
return {
instance: {
@@ -212,8 +199,12 @@ function buildLocalInfo() {
features,
updatedAt: Date.now(),
},
roster,
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
// Only public roster entries reach the builder; queue metadata follows the
// same visibility boundary instead of creating separate orphan queue rows.
roster: roster.map((rover) => ({
...rover,
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
})),
users,
roomCameras,
socials: features.socials ? getConfiguredSocials(config) : [],
@@ -349,7 +340,6 @@ function normalizeRemotePayload(entry, payload) {
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
},
roster: Array.isArray(payload?.roster) ? payload.roster : [],
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
users: Array.isArray(payload?.users) ? payload.users : [],
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
socials: Array.isArray(payload?.socials) ? payload.socials : [],
@@ -419,7 +409,6 @@ function markOffline(entry, error) {
publicUrl: previous.instance?.publicUrl || entry.url,
},
roster: previous.roster || [],
turnQueues: previous.turnQueues || {},
users: previous.users || [],
roomCameras: previous.roomCameras || [],
socials: previous.socials || [],
@@ -370,11 +370,6 @@ function createSnapshotEngine(deps) {
.filter((entry) => {
const roverId = entry?.roverId ? String(entry.roverId) : null;
if (!roverId) return true;
/*
This is a chat transcript filter, not a physical-rover filter. PTZ
chat intentionally carries a rover-like id so transcript consumers can
render it consistently, even though roverManager cannot replay that id.
*/
return isPublicChatTargetId(roverId);
});
const chatRecent = allRecentMessages
@@ -430,6 +425,8 @@ function createSnapshotEngine(deps) {
type: 'chat',
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
text: entry.text || '',
operating_mode: entry.operatingMode || null,
operating_mode_name: entry.operatingModeDisplay?.name || null,
rover_id: roverId,
rover_ctx: { ...baseCtx, ...storedCtx },
};
@@ -0,0 +1,74 @@
const EventEmitter = require('events');
const operatingModeEvents = new EventEmitter();
const transitions = new WeakMap();
function getOperatingMode(socket) {
// Handshake intent reserves the mode before role-driven assignment runs.
// It grants no camera permissions; entry still checks access and readiness.
return socket?.data?.operatingMode
|| (socket?.handshake?.auth?.operatingMode === 'ptz' ? 'ptz' : 'rover');
}
async function transition(socket, operatingMode) {
// Resolve services at the action boundary to avoid a dependency cycle with
// assignment and camera policy, which both read operating mode.
const assignment = require('../assignmentService');
const roverManager = require('../roverManager');
const ptz = require('../ptzCameraService');
if (operatingMode === 'rover' && getOperatingMode(socket) === 'rover') {
return { operatingMode, state: ptz.getPublicState(socket) };
}
if (operatingMode === 'ptz') {
await ptz.prepareParticipation(socket);
if (socket.disconnected) throw new Error('Session disconnected');
ptz.checkParticipationAccess(socket);
const leave = roverManager.canLeaveCurrentRover(socket);
if (!leave.ok) throw new Error(leave.message);
// Block new rover ownership before release events notify other services.
socket.data.operatingMode = 'ptz';
assignment.releaseForOperatingMode(socket);
socket.data.ptzEntered = true;
} else {
ptz.releaseTurn(socket);
socket.data.ptzEntered = false;
socket.data.operatingMode = 'rover';
assignment.resumeAssignment(socket);
}
operatingModeEvents.emit('change', { socket, operatingMode });
return { operatingMode, state: ptz.getPublicState(socket) };
}
function enqueueTransition(socket, action) {
// Camera initialization can yield. Serialize switches and turn release per
// socket so a late entry cannot undo a subsequent exit or release request.
const previous = transitions.get(socket) || Promise.resolve();
const next = previous.catch(() => {}).then(() => {
if (socket.disconnected) throw new Error('Session disconnected');
return action();
});
transitions.set(socket, next);
return next;
}
function setOperatingMode(socket, operatingMode) {
if (!['rover', 'ptz'].includes(operatingMode)) {
return Promise.reject(new Error('Invalid operating mode'));
}
return enqueueTransition(socket, () => transition(socket, operatingMode));
}
function runPtzTurnAction(socket, action) {
return enqueueTransition(socket, () => {
const ptz = require('../ptzCameraService');
if (action === 'release') return ptz.releaseTurn(socket);
if (action !== 'request') throw new Error('Invalid PTZ turn action');
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
throw new Error('Enter PTZ before requesting a turn');
}
return ptz.claimTurn(socket);
});
}
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
@@ -396,11 +396,6 @@ async function runDecision(triggerReason) {
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => {
if (!entry?.roverId) return true;
/*
Chat context should preserve every public chat target, including the
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
it is owned by ptzCameraService instead of roverManager.
*/
return isPublicChatTargetId(entry.roverId);
})
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
@@ -2,7 +2,6 @@
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
const crypto = require('crypto');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { spawn } = require('child_process');
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
let disposed = false;
let playbackProc = null;
let playbackSeq = 0;
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
}, 1200);
}
async function playFile(filePath, context = {}) {
if (!audioEnabled) throw new Error('PTZ audio disabled');
async function playFile(filePath, context = {}, authorize = () => {}) {
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
authorize();
const neolinkConfigPath = await ensureNeolinkConfig();
const stat = await fsp.stat(filePath);
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
// Rendering and config IO may outlive a turn or camera configuration.
if (disposed) throw new Error('PTZ audio configuration changed');
authorize();
stopActivePlayback('new-playback');
const seq = ++playbackSeq;
const args = [
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
return { pid: proc.pid || null, seq };
}
async function speakText(text, ttsOptions = {}, context = {}) {
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
const rendered = await ensureTtsFile(text, ttsOptions);
await playFile(rendered.filePath, {
...context,
engine: rendered.engine,
cached: rendered.cached,
});
}, authorize);
return rendered;
}
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
getState,
speakText,
stopActivePlayback,
dispose: () => {
disposed = true;
stopActivePlayback('configuration-change');
},
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
// by the service, so queue transitions cannot create physical rover ownership.
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
let timer = null;
function remove(socketId) {
state.queue = state.queue.filter((id) => id !== socketId);
}
function stopOperator(reason) {
clearTimeout(timer);
timer = null;
const previous = state.operatorSocketId;
state.operatorSocketId = null;
state.deadline = null;
state.generation += 1;
if (!previous) return;
revokeVideo(previous);
stopMotion(reason).catch(() => {});
events.emit('operator', { socketId: previous, action: 'release', reason });
}
function scheduleTurn() {
// Uncontested control has no deadline. Starting a new wait gets a full turn,
// while later queue changes must not reset an already running countdown.
if (!state.operatorSocketId || !state.queue.length) {
clearTimeout(timer);
timer = null;
state.deadline = null;
return;
}
if (timer) return;
const duration = getTurnDurationMs();
state.deadline = Date.now() + duration;
timer = setTimeout(() => {
timer = null;
state.queue = state.queue.filter(eligible);
if (state.queue.length) {
// Preserve PTZ's explicit request-to-rejoin rule after a handoff.
stopOperator('turn-expired');
advance('turn-expired');
} else {
scheduleTurn();
emitChange('turn-uncontested');
}
}, duration);
}
function eligible(socketId) {
const socket = io.sockets.sockets.get(socketId);
return socket && !socket.disconnected && canParticipate(socket);
}
function advance(reason) {
state.queue = state.queue.filter(eligible);
while (!state.operatorSocketId && state.queue.length) {
const next = state.queue.shift();
if (!eligible(next)) continue;
state.operatorSocketId = next;
state.generation += 1;
events.emit('operator', { socketId: next, action: 'active' });
}
scheduleTurn();
emitChange(reason);
}
function claim(socket) {
if (!eligible(socket.id)) throw new Error('PTZ participation required');
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
state.queue.push(socket.id);
advance('queue-join');
}
function release(socketId, reason = 'turn-release') {
remove(socketId);
if (state.operatorSocketId === socketId) stopOperator(reason);
advance(reason);
}
function reconcile() {
state.queue = state.queue.filter(eligible);
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
advance('access-change');
}
function clear(reason) {
state.queue = [];
stopOperator(reason);
}
return {
state, claim, release, reconcile, clear,
revoke: (reason) => { stopOperator(reason); advance(reason); },
};
}
module.exports = { createParticipation };
@@ -0,0 +1,123 @@
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
function registerSocketGateway(deps) {
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
listPresets, gotoPreset, createPreset, removePreset, passesMode,
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
getSnapshot, removeSnapshotSubscriptions } = deps;
io.on('connection', (socket) => {
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
participation.revoke('admin-revoke');
cb({ ok: true });
});
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
catch (err) { cb({ error: err.message }); }
});
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
/*
Acknowledge acceptance of the newest desired state immediately. The
serialized ONVIF pump deliberately runs independently of Socket.IO
request latency so browser heartbeats cannot accumulate while waiting
for a camera SOAP response.
*/
cb(acceptMotionIntent(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, status: await getStatus(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, light: await setSpotlight(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, ir: await setIr(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, presets: await listPresets(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await gotoPreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await createPreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await removePreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
const ids = normalizeSnapshotIds(payload);
addSnapshotSubscription(socket, ids);
const lastSnapshotState = getSnapshot();
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
cb({ ok: true, subscribed: ids });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
const { payload } = normalizeSocketArgs(firstArg, secondArg);
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
});
socket.on('disconnect', () => {
participation.release(socket.id, 'disconnect');
removeSnapshotSubscriptions(socket.id);
emitChange('disconnect');
});
});
}
module.exports = { registerSocketGateway };
@@ -0,0 +1,9 @@
const labels = {
...require('../turnService/labels'),
waiting: 'Someone else has control',
active: 'You control the camera',
showName: 'Show camera name',
hideName: 'Hide camera name',
};
module.exports = labels;
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
replay`, follows the camera currently controlled by that socket just as it
follows an assigned rover below.
isOperator is deliberately stricter than PTZ access or queue membership:
turn.ownsControl is deliberately stricter than PTZ access or queue membership:
spectators and users waiting for a camera turn must not silently replay a
camera they are not currently operating. Keeping this rule here also makes
every web replay entry point share the same default instead of teaching the
chat-command adapter about PTZ-specific state.
*/
if (ptzCameraService.getPublicState(socket).isOperator) {
if (ptzCameraService.getPublicState(socket).turn.ownsControl) {
const source = ptzCameraService.getReplaySource();
if (!source) return [];
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
@@ -1,7 +1,7 @@
// Room-Camera Configuration
// Purpose: Defines the optional named snapshot and stream camera catalog.
// Scope: Contains configuration metadata only and never contacts a camera.
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'roomCameras',
@@ -10,6 +10,7 @@ module.exports = {
// initial configuration while the feature switch prevents network requests.
defaultValue: {
enabled: false,
streamIntervalMs: 2000,
cameras: [
{
id: 'lobby',
@@ -29,6 +30,7 @@ module.exports = {
},
schema: strictObject({
enabled: boolean({ description: 'Immediately publishes the configured room-camera catalog and enables camera snapshots and streams.' }),
streamIntervalMs: integer({ title: 'Stream interval (ms)', description: 'Interval for sending room camera images to clients. Lowering this increases bandwidth usage FAST because it sends a full image each time.', minimum: 1 }),
cameras: {
type: 'array',
description: 'Room cameras available to the web UI and replay system.',
@@ -8,10 +8,12 @@ const replay = require('../replayEngineV2/roomCameraReplayBuilder');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
let enabled = false;
let streamIntervalMs = 2000;
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
function applyRoomCameraConfig(roomCameraConfig = {}) {
enabled = Boolean(roomCameraConfig.enabled);
streamIntervalMs = roomCameraConfig.streamIntervalMs ?? 2000;
// Loading an empty catalog on disable causes the snapshot engine's existing
// update listener to close every stream and timer without unregistering the
// stable browser gateway.
@@ -19,6 +21,7 @@ function applyRoomCameraConfig(roomCameraConfig = {}) {
}
registerRoomCameraSocketGateway({
getStreamIntervalMs: () => streamIntervalMs,
getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
@@ -8,7 +8,6 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const SUBSCRIBE_LIMIT = 50;
const SUBSCRIBE_WINDOW_MS = 10000;
const STREAM_INTERVAL_MS = 2000;
function passesMode(socket) {
const mode = getMode();
@@ -20,7 +19,7 @@ function passesMode(socket) {
return true;
}
function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents }) {
function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents, getStreamIntervalMs }) {
const cameraSubscribers = new Map();
const socketSubscriptions = new Map();
const subscribeBuckets = new Map();
@@ -82,7 +81,7 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
}
const lastSent = lastMap.get(id) || 0;
const now = ts || Date.now();
if (now - lastSent < STREAM_INTERVAL_MS) return;
if (now - lastSent < getStreamIntervalMs()) return;
lastMap.set(id, now);
sendFrame(socket, id, { ts }, buffer);
});
@@ -1,15 +1,18 @@
// Match actuator configuration, not a rover's display name or changing telemetry.
const { isDeepStrictEqual } = require('node:util');
const POLICY_VERSION = 5;
function controlProfile(meta = {}) {
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
return Object.fromEntries(['cameraServo', 'headlight', 'roomba', 'platform']
.map((key) => [key, meta?.[key] ?? null]));
}
function compatible(model, record) {
if (model?.specification?.version !== 2 || !record) return false;
if (model?.specification?.version !== POLICY_VERSION || !record) return false;
const profile = controlProfile(record.meta);
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
// Retained recordings may include removed actuator fields. Compare only
// controls this policy actually uses, on both sides of the boundary.
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(controlProfile(saved), profile));
}
module.exports = { controlProfile, compatible };
module.exports = { POLICY_VERSION, controlProfile, compatible };
@@ -20,7 +20,7 @@ module.exports = {
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
driving: strictObject({
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum predicted action probability for issuing a new control event.' }),
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum model score for issuing a command, including drive and stop commands.' }),
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
recording: strictObject({
@@ -1,12 +1,14 @@
// Export an explicit allowlist, never the raw socket/session/configuration object.
const { redact } = require('./errors');
const { POLICY_VERSION } = require('./capabilities');
const os = require('node:os');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const applicationVersion = require('../../../package.json').version;
const hash = createHash('sha256');
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py',
'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) {
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py', 'workers/observations.py', 'workers/learning.py',
'capabilities.js', 'errors.js', 'storage.js', 'runtime.js', 'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) {
hash.update(fs.readFileSync(path.join(__dirname, file)));
}
const implementationSha256 = hash.digest('hex');
@@ -28,8 +30,8 @@ function report(state, controller) {
const metrics = training.diagnostics || {};
const session = state.session;
return {
reportVersion: 1, generatedAtUnixMs: state.updatedAt,
software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version,
reportVersion: 3, generatedAtUnixMs: state.updatedAt,
software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version,
dependencies: metrics.dependencies || null },
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
settings: state.limits,
@@ -37,23 +39,25 @@ function report(state, controller) {
freeBytes: state.recording.freeBytes, pendingBytes: state.recording.pendingBytes,
droppedPending: state.recording.droppedPending,
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
status: item.video, droppedEvents: item.droppedEvents, errorCategory: errorCategory(item.error) })) },
status: item.video, droppedEvents: item.droppedEvents, error: redact(item.error), failureDetails: item.failureDetails || null, stderr: redact(item.stderr), errorCategory: errorCategory(item.error) })) },
training: { status: training.status, steps: training.steps, examples: training.examples,
selection: training.selection || null, evaluatedSteps: training.evaluatedSteps ?? null,
loss: training.loss, losses: training.losses || null, publication: training.publication || null,
trainingAudit: metrics.trainingAudit || null,
evaluation: training.evaluation || null, dataset: metrics.dataset || training.dataset || null,
resources: { decodeSeconds: metrics.decodeSeconds ?? null, trainingSeconds: metrics.trainingSeconds ?? null,
evaluationSeconds: metrics.evaluationSeconds ?? null, checkpointSeconds: metrics.checkpointSeconds ?? null, cpuSeconds: metrics.cpuSeconds ?? null,
evaluationSeconds: metrics.evaluationSeconds ?? null, probeSeconds: metrics.probeSeconds ?? null, beforeAuditSeconds: metrics.beforeAuditSeconds ?? null, checkpointSeconds: metrics.checkpointSeconds ?? null, cpuSeconds: metrics.cpuSeconds ?? null,
peakRssMiB: metrics.peakRssMiB ?? null },
history: training.history || [], rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
history: training.history || [], rejectionReasons: (training.rejectionReasons || []).map(redact), rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
error: redact(training.error), errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
model: session ? { id: session.modelId, checkpointId: session.checkpointId, threshold: session.threshold, appliedThreshold: session.appliedThreshold } : null,
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
wheels: submission.wheels, result: submission.result === 'issued' ? 'issued' : errorCategory(submission.result) })) })) } : null,
command: submission.command, result: redact(submission.result) })) })) } : null,
sensorFields: metrics.policy?.sensorFields || null,
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
events: state.activity.map(({ at, message }) => ({ atUnixMs: at, message })),
events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })),
unavailable: ['browser_video_latency', 'actual_physical_command_execution', 'live_training_cpu_utilization'],
};
}
@@ -9,7 +9,8 @@ const { recordCommandRequest } = require('../commandService/recording');
const { acquireModel } = require('./models');
const { compatible } = require('./capabilities');
const OPERATING = new Set(['drive', 'motors', 'servo', 'headlight', 'laser', 'horn', 'peripheral', 'song', 'raw']);
const OPERATING = new Set(['drive', 'servo', 'headlight', 'raw']);
const MODE_COMMANDS = new Set([128, 131, 132, 143].map((opcode) => Buffer.from([opcode]).toString('base64')));
const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value) || 0));
function canControl(socket, roverId) {
@@ -34,14 +35,12 @@ function createDriving({ root, config, onChange, activity }) {
if (session.status === 'stopping') return;
if (!canControl(session.socket, session.roverId)) { stop(session.roverId, 'Control permission lost'); return; }
if (!OPERATING.has(command?.type)) throw new Error('Unsupported model command');
if (command.type === 'raw' && !MODE_COMMANDS.has(command.raw)) throw new Error('Unsupported model operating-mode command');
const record = roverManager.rovers.get(session.roverId);
if (command.type === 'drive') {
const limit = Math.min(500, Number(record.meta?.maxWheelSpeed) || 500);
command.driveDirect = { left: Math.round(clamp(command.driveDirect?.left, -limit, limit)), right: Math.round(clamp(command.driveDirect?.right, -limit, limit)) };
}
if (command.type === 'motors') {
command.motorPwm = { main: clamp(command.motorPwm?.main, -127, 127), side: clamp(command.motorPwm?.side, -127, 127), vacuum: clamp(command.motorPwm?.vacuum, 0, 127) };
}
if (command.type === 'servo') {
const servo = record.meta?.cameraServo;
if (!servo?.enabled) { session.submissions.push({ command, result: 'Servo unavailable' }); return; }
@@ -64,8 +63,6 @@ function createDriving({ root, config, onChange, activity }) {
// A stop must still reach the rover after turn permission is revoked.
for (const command of [
{ type: 'drive', driveDirect: { left: 0, right: 0 } },
{ type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } },
{ type: 'horn', horn: { action: 'stop' } },
]) { try { issueCommand(roverId, command); } catch { /* Rover may already be offline. */ } }
session.worker.kill('SIGTERM');
const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000);
@@ -127,21 +124,19 @@ function createDriving({ root, config, onChange, activity }) {
session.latencyMs = message.latencyMs;
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
session.appliedThreshold = message.threshold;
session.wheelSpeeds = message.wheelSpeeds;
session.sensorPresent = message.sensorPresent;
session.submissions = [];
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels';
session.predictionStatus = message.commands.length ? `${message.commands.length} command(s) proposed` : 'No command proposed';
// Manual commands intentionally do not stop or pause this controller.
for (const command of message.commands) transmit(session, command);
// Bound report history independently of camera and UI frame rates.
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
session.history.push({ at: Date.now(), wheelSpeeds: session.wheelSpeeds,
session.history.push({ at: Date.now(),
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason }) => ({ slot, score, reason })),
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason, command }) => ({ slot, score, reason, command })),
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, command })) });
if (session.history.length > 60) session.history.shift();
}
} catch (error) { stop(roverId, error.message); break; }
@@ -0,0 +1,18 @@
// Keep actionable failures while removing credentials and user identifiers.
function redact(value) {
if (value == null) return null;
return String(value).slice(0, 12000)
.replace(/\b(?:https?|rtsp|rtsps):\/\/[^\s'"<>]+/gi, '[redacted URL]')
.replace(/\b(Bearer\s+)\S+/gi, '$1[redacted]')
.replace(/((?:password|token|secret|authorization|cookie|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
.replace(/\bcu_[a-z0-9]+/gi, '[redacted user]')
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[redacted email]')
.replace(/\/(?:home|Users)\/[^/\s]+/g, '/[user]');
}
function errorDetails(error, operation) {
return { atUnixMs: Date.now(), operation: redact(operation),
name: redact(error?.name), message: redact(error?.message ?? error),
code: redact(error?.code), syscall: redact(error?.syscall),
path: redact(error?.path), stack: redact(error?.stack) };
}
module.exports = { redact, errorDetails };
@@ -1,3 +1,4 @@
const { POLICY_VERSION } = require('./capabilities');
// Mutable aliases reference immutable checkpoints. All readers and retention share
// a queue so a model cannot disappear between resolving its alias and loading it.
const fs = require('node:fs/promises');
@@ -19,7 +20,7 @@ async function index(root) {
}
async function metadata(directory) {
const model = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8'));
if (model.id !== path.basename(directory)) throw new Error('Model identity mismatch');
if (model?.id !== path.basename(directory)) throw new Error('Model identity mismatch');
return model;
}
async function prune(root, state) {
@@ -82,6 +83,7 @@ function saveSnapshot(root) {
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
const source = path.join(checkpointRoot(root), state.candidate);
const original = await metadata(source);
if (original.specification?.version !== POLICY_VERSION) throw new Error('Waiting for a completed command-policy checkpoint');
const id = randomUUID();
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
@@ -98,6 +100,58 @@ function saveSnapshot(root) {
return model;
});
}
// Retire recognized older policies before workers start. Unknown/malformed or
// future manifests are not evidence that a directory is disposable.
function removeObsoleteModels(root) {
return serial(async () => {
const obsolete = [];
for (const directory of [path.join(root, 'models'), checkpointRoot(root)]) {
let entries;
try { entries = await fs.readdir(directory, { withFileTypes: true }); }
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
for (const entry of entries) {
if (!entry.isDirectory() || !UUID.test(entry.name)) continue;
const target = path.join(directory, entry.name);
let model;
try { model = await metadata(target); }
catch (error) {
if (error.code === 'ENOENT' || error instanceof SyntaxError || error.message === 'Model identity mismatch') continue;
throw error;
}
const version = model.specification?.version;
if (Number.isInteger(version) && version >= 1 && version < POLICY_VERSION) {
obsolete.push({ target, checkpoint: directory === checkpointRoot(root), id: entry.name });
}
}
}
const state = await index(root);
const oldCheckpoints = new Set(obsolete.filter((entry) => entry.checkpoint).map((entry) => entry.id));
let changed = false;
for (const alias of ['candidate', 'latest', 'previous']) {
if (oldCheckpoints.has(state[alias])) { delete state[alias]; changed = true; }
}
// Remove aliases atomically first, so interruption cannot leave the catalog
// referring to a checkpoint that cleanup already deleted.
if (changed) {
const filename = path.join(checkpointRoot(root), 'index.json');
await fs.writeFile(`${filename}.tmp`, JSON.stringify(state));
await fs.rename(`${filename}.tmp`, filename);
}
for (const { target } of obsolete) {
if (leases.has(target)) retired.add(target);
else { await fs.rm(target, { recursive: true, force: true }); retired.delete(target); }
}
// These filenames belong to superseded optimizer/scheduler lineages, not
// policy versions. The current command policy uses lineage v6.
for (const suffix of ['', '-v1', '-v2', '-v3', '-v4', '-v5']) {
for (const filename of [`resume${suffix}.pt`, `resume${suffix}.tmp`,
`schedule${suffix}.json`, `schedule${suffix}.json.tmp`]) {
await fs.rm(path.join(root, 'training', filename), { force: true });
}
}
});
}
function listModels(root) {
return serial(async () => {
let entries;
@@ -116,4 +170,4 @@ function listModels(root) {
return models;
});
}
module.exports = { listModels, acquireModel, adoptCheckpoint, saveSnapshot };
module.exports = { removeObsoleteModels, listModels, acquireModel, adoptCheckpoint, saveSnapshot };
@@ -1,5 +1,6 @@
// One bounded recording session. Video packets retain receiver wall-clock PTS;
// telemetry uses the same server clock, not an assumed browser playback clock.
const { errorDetails, redact } = require('./errors');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
@@ -34,6 +35,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
let dockedAt = null;
let pruning = Promise.resolve();
let failure = null;
let failureDetails = null;
let stopping = false;
let stopPromise;
let child;
@@ -43,7 +45,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
let resolveVideo;
const videoClosed = new Promise((resolve) => { resolveVideo = resolve; });
output.on('error', (error) => {
failure = error.message;
failure = error.message; failureDetails = errorDetails(error, 'recording session');
// Stop file growth immediately when telemetry is no longer writable.
child?.kill('SIGTERM');
});
@@ -76,7 +78,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
output.write(line);
} catch (error) {
metadata.droppedEvents += 1;
failure = error.message;
failure = error.message; failureDetails = errorDetails(error, 'recording session');
child?.kill('SIGTERM');
}
return true;
@@ -97,7 +99,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
'-reset_timestamps', '0', path.join(directory, 'video-%09d.mkv'),
], { stdio: ['ignore', 'ignore', 'pipe'] });
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4096); });
child.on('error', (error) => { failure = error.message; });
child.on('error', (error) => { failure = error.message; failureDetails = errorDetails(error, 'recording session'); });
child.once('close', (code, signal) => {
childClosed = true;
videoResult = { code, signal, stderr };
@@ -131,7 +133,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
if (!metadata.bufferOnly || stopping) return;
if (stat.mtimeMs < Date.now() - 5000) await fsp.unlink(filename);
}
}).catch((error) => { failure = error.message; child.kill('SIGTERM'); });
}).catch((error) => { failure = error.message; failureDetails = errorDetails(error, 'recording session'); child.kill('SIGTERM'); });
}
async function stop(reason) {
@@ -158,9 +160,10 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
metadata.endedAt = Date.now();
metadata.reason = reason;
metadata.error = failure;
metadata.failureDetails = failureDetails;
metadata.video.result = videoResult;
await saveMetadata(directory, metadata);
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, stderr });
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, failure: failureDetails, stderr });
})();
return stopPromise;
}
@@ -173,7 +176,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
get failed() { return Boolean(failure); },
get stopping() { return stopping; },
getStatus: () => ({ id, roverId, startedAt: metadata.startedAt, droppedEvents: metadata.droppedEvents,
video: childClosed ? 'stopped' : metadata.bufferOnly ? 'Dock lead-in buffer' : 'Recording human driving', error: failure, stopping }),
video: childClosed ? 'stopped' : metadata.bufferOnly ? 'Dock lead-in buffer' : 'Recording human driving', error: redact(failure), failureDetails, stderr: redact(stderr), stopping }),
// Process exit is synchronous; normal disable uses stop() and awaits close.
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
};
@@ -1,4 +1,5 @@
// Enabled-only recording, training, controller, and card lifecycle.
const { redact, errorDetails } = require('./errors');
const fs = require('node:fs/promises');
const path = require('node:path');
const { resolveDataPath } = require('../../helpers/dataPaths');
@@ -13,6 +14,7 @@ const { createRecording } = require('./recording');
const { recoverRecordings, enforceRetention } = require('./storage');
const { createTrainer } = require('./training');
const { createDriving } = require('./driving');
const { removeObsoleteModels } = require('./models');
const { createGateway } = require('./socketGateway');
function createRuntime({ config, logger }) {
@@ -21,7 +23,9 @@ function createRuntime({ config, logger }) {
const recentActivity = [];
function activity(message, details = {}) {
recentActivity.push({ at: Date.now(), message, roverId: details.roverId || null,
detail: details.reason || details.error || details.model || null });
detail: redact(details.reason || details.error || details.model),
error: details.failure || (details.error ? errorDetails(details.error, message) : null),
stderr: redact(details.stderr) });
if (recentActivity.length > 40) recentActivity.shift();
}
const baseLogger = logger;
@@ -76,7 +80,7 @@ function createRuntime({ config, logger }) {
// The serialized maintenance pass still owns closing/removing the session.
for (const [id, session] of sessions) {
if (!next.has(id)) session.stop('no-human-controller').catch((error) => {
logger.warn('Unable to finalize unattended recording', { roverId: id, error: error.message });
logger.warn('Unable to finalize unattended recording', { roverId: id, error: error.message, failure: errorDetails(error, 'rover learning runtime') });
});
}
schedule();
@@ -184,7 +188,7 @@ function createRuntime({ config, logger }) {
await trainer.tick(new Set([...sessions.values()].map((session) => session.id)));
} catch (error) {
// Training availability must not tear down otherwise healthy capture.
logger.error('Unable to schedule training', { error: error.message });
logger.error('Unable to schedule training', { error: error.message, failure: errorDetails(error, 'rover learning runtime') });
}
}
}
@@ -197,7 +201,7 @@ function createRuntime({ config, logger }) {
// The next tick is scheduled after completion: disk slowness cannot build
// an unbounded backlog of retention tasks or overlapping recorder starts.
maintenance = maintain().catch(async (error) => {
logger.error('Recording maintenance failed; closing recorders', { error: error.message });
logger.error('Recording maintenance failed; closing recorders', { error: error.message, failure: errorDetails(error, 'recording maintenance') });
await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'storage-error')));
}).finally(() => {
busy = false;
@@ -215,12 +219,13 @@ function createRuntime({ config, logger }) {
// Other server services can exit the process before asynchronous cleanup
// finishes. Signal children immediately, and keep an exit fallback too.
for (const session of sessions.values()) session.stop('server-shutdown').catch(() => undefined);
stop().catch((error) => logger.warn('Recording shutdown failed', { error: error.message }));
stop().catch((error) => logger.warn('Recording shutdown failed', { error: error.message, failure: errorDetails(error, 'rover learning runtime') }));
}
async function start() {
await fs.mkdir(recordingsRoot, { recursive: true });
await recoverRecordings(recordingsRoot);
await removeObsoleteModels(root);
await trainer.start();
running = true;
driving.attach();
@@ -10,9 +10,17 @@ async function listRecordings(root) {
if (!entry.isDirectory() || !/^\d+-[0-9a-f-]{36}$/.test(entry.name)) continue;
const directory = path.join(root, entry.name);
let bytes = 0;
for (const file of await fs.readdir(directory, { withFileTypes: true })) {
let files;
try { files = await fs.readdir(directory, { withFileTypes: true }); }
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
for (const file of files) {
if (!file.isFile()) continue;
bytes += (await fs.stat(path.join(directory, file.name))).size;
try { bytes += (await fs.stat(path.join(directory, file.name))).size; }
catch (error) {
// Dock-buffer pruning and atomic metadata replacement can remove a
// listed file before stat. Absence is normal; other I/O failures are not.
if (error.code !== 'ENOENT') throw error;
}
}
recordings.push({ id: entry.name, directory, bytes, startedAt: Number(entry.name.split('-')[0]) });
}
@@ -1,9 +1,10 @@
// One owned finite worker at a time. Leases are acquired in the runtime's
// serialized maintenance pass, before retention can next inspect recordings.
const { errorDetails } = require('./errors');
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { randomInt } = require('node:crypto');
const { randomInt, createHash } = require('node:crypto');
const { adoptCheckpoint } = require('./models');
const { listRecordings } = require('./storage');
@@ -32,21 +33,28 @@ function createTrainer({ root, config, actionThreshold, logger }) {
}
}
try {
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v3.json'), 'utf8'));
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v6.json'), 'utf8'));
ledger = saved.sessions;
state = { ...saved.progress, status: 'waiting' };
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
// Retry this reader-specific rejection once after the fixed-grid repair.
// Keep optimizer progress and unrelated recording failures intact.
if (saved.samplingVersion !== 2) {
for (const [id, entry] of Object.entries(ledger)) {
if (entry.rejected === 'No complete, aligned observation/action windows') delete ledger[id];
}
}
} catch (error) { if (error.code !== 'ENOENT') throw error; }
} catch (error) {
startupError = true;
state = { ...state, status: 'error', error: error.message };
logger.error('Training initialization failed; recording remains available', { error: error.message });
logger.error('Training initialization failed; recording remains available', { error: error.message, failure: errorDetails(error, 'training initialization') });
}
}
async function saveLedger() {
const filename = path.join(trainingRoot, 'schedule-v3.json');
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
const filename = path.join(trainingRoot, 'schedule-v6.json');
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ samplingVersion: 2, sessions: ledger, progress: state }));
await fs.rename(`${filename}.tmp`, filename);
}
@@ -74,10 +82,27 @@ function createTrainer({ root, config, actionThreshold, logger }) {
const other = randomInt(index + 1);
[eligible[index], eligible[other]] = [eligible[other], eligible[index]];
}
const selected = eligible.slice(0, 4);
// Match workers/train.py exactly. Never move a previously trained session
// into evaluation just to fill a slot: that would contaminate the holdout.
const partitions = { training: [], heldOut: [] };
for (const id of eligible) {
const heldOut = parseInt(createHash('sha256').update(id).digest('hex').slice(0, 8), 16) % 5 === 0;
partitions[heldOut ? 'heldOut' : 'training'].push(id);
}
const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4);
const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 2) : [];
const selected = [...trainingSessions, ...heldOutSessions];
const selection = {
atUnixMs: Date.now(),
availableTraining: partitions.training.length, availableHeldOut: partitions.heldOut.length,
selectedTraining: trainingSessions.length, selectedHeldOut: heldOutSessions.length,
reason: !trainingSessions.length ? 'Waiting for completed usable training-partition recordings'
: !heldOutSessions.length ? 'Training without evaluation: no eligible held-out recordings' : null,
};
state = { ...state, selection };
nextRun = Date.now() + config.intervalSeconds * 1000;
if (!selected.length) {
state = { ...state, status: 'waiting', reason: 'Waiting for completed usable human recordings' };
state = { ...state, status: 'waiting', reason: selection.reason };
await saveLedger();
return;
}
@@ -85,7 +110,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
await fs.writeFile(jobPath, JSON.stringify({ root, config, actionThreshold, sessions: selected }));
if (stopped) return;
selected.forEach((id) => leases.add(id));
state = { ...state, status: 'training', reason: null, sessions: selected };
state = { ...state, status: 'training', reason: selection.reason, sessions: selected };
state.startedAt = Date.now();
child = spawn(config.python, [path.join(__dirname, 'workers/train.py'), '--job', jobPath], {
stdio: ['ignore', 'pipe', 'pipe'],
@@ -141,6 +166,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
completedAt: Date.now(), elapsedSeconds: result.elapsedSeconds ?? null, error: null,
examples: result.examples ?? state.examples, loss: result.loss ?? null,
evaluatedSteps: result.trained ? result.steps : state.evaluatedSteps,
diagnostics: result.diagnostics ?? state.diagnostics, publication: result.publication ?? state.publication, evaluation: result.evaluation ?? state.evaluation,
reason: result.reason ?? 'Waiting for the next scheduled training job' };
await saveLedger();
@@ -148,10 +174,12 @@ function createTrainer({ root, config, actionThreshold, logger }) {
}
} catch (error) {
state = { ...state, status: 'error', error: error.message };
logger.error('Training result persistence failed', { error: error.message });
logger.error('Training result persistence failed', { error: error.message, failure: errorDetails(error, 'training result persistence') });
} finally {
history.push({ at: Date.now(), status: state.status, steps: state.steps, loss: state.loss,
losses: state.losses || null, evaluation: state.evaluation || null,
losses: state.losses || null, evaluation: state.evaluation ? Object.fromEntries(
['status', 'windows', 'precision', 'recall', 'f1', 'predictedActions', 'positiveActions', 'numericMse', 'missedStops', 'gateReasons']
.map((key) => [key, state.evaluation[key]])) : null,
elapsedSeconds: state.elapsedSeconds || null });
if (history.length > 20) history.shift();
leases.clear();
@@ -8,8 +8,9 @@ from pathlib import Path
import av
import numpy as np
from observations import decision_frames, history_frames
from policy import SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector
def load_events(directory):
@@ -47,7 +48,7 @@ def load_events(directory):
return metadata, sorted(events, key=lambda e: e['ts'])
def prepare(directory, schema, stats):
def prepare(directory, schema, stats, extend):
metadata, events = load_events(directory)
requests = {}
accepted = set()
@@ -72,28 +73,28 @@ def prepare(directory, schema, stats):
if not sensors:
raise ValueError('No sensor observations')
candidate = copy.deepcopy(schema)
history, targets, wheels = [], [], [(stamp, None) for stamp in boundaries]
history, targets, unsupported_times = [], [], []
unsupported = {}
dispatch_delays = []
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][32:] for row in sensors], axis=0).tolist()})
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][len(SENSORS):] for row in sensors], axis=0).tolist()})
for data in dispatches:
command = data.get('command') or {}
request = requests.get(data.get('requestId'))
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted
and data.get('commandId') not in failed)
stamp = data['ts']
if command.get('type') == 'drive':
direct = command.get('driveDirect') or {}
pair = [direct.get('left'), direct.get('right')]
if human and all(isinstance(v, (int, float)) and math.isfinite(v) for v in pair):
wheels.append((stamp, np.clip(np.array(pair, dtype=np.float32), -metadata.get('wheelSpeedLimit', 500), metadata.get('wheelSpeedLimit', 500)) / 500))
else:
wheels.append((stamp, None))
elif command.get('type') in ('raw', 'reset', 'reboot', 'update', 'sensorStream'):
# Raw OI can change mode or motion; do not assume the last wheel
# command survives it. A later explicit human drive resolves intent.
wheels.append((stamp, None))
boundaries.append(stamp)
encoded = encode_command(command, candidate, extend=True)
if command.get('type') in ('reset', 'reboot', 'update'):
boundaries.append(data['ts'])
try:
encoded = encode_command(command, candidate, extend=extend)
except ValueError as error:
# Unrepresentable controls invalidate only nearby observations/targets,
# not the rest of a recording. Never turn them into negative labels.
request = requests.get(data.get('requestId'))
unsupported_times.extend([data['ts'], request['ts'] if request else data['ts']])
key = json.dumps(command, sort_keys=True)
if key not in unsupported and len(unsupported) < 32:
unsupported[key] = {'command': command, 'reason': str(error), 'count': 0}
if key in unsupported:
unsupported[key]['count'] += 1
continue
if encoded is None:
continue
stamp = data['ts']
@@ -104,36 +105,43 @@ def prepare(directory, schema, stats):
target_time = request['ts']
if not 0 <= stamp - target_time <= 1000:
raise ValueError('Invalid request/dispatch timing')
dispatch_delays.append(stamp - target_time)
targets.append((target_time, *encoded))
else:
# Server safety overrides belong in history, never demonstration
# targets. Exclude windows around them rather than teaching inaction.
boundaries.append(stamp)
if not targets:
stats['requestToDispatchMs'] = {'mean': sum(dispatch_delays) / max(1, len(dispatch_delays)),
'max': max(dispatch_delays, default=0)}
stats['acceptedCommandEvents'] = len(targets)
stats['representedCommandEvents'] = 0
stats['commandEventCounts'] = [0] * SLOTS
stats['unsupportedCommands'] = list(unsupported.values())
stats['unsupportedCommandEvents'] = len(unsupported_times) // 2
if not targets and not unsupported_times:
raise ValueError('No accepted client operating commands')
schema[:] = candidate
return metadata, sensors, sorted(wheels, key=lambda x: x[0]), sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
return metadata, sensors, sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries), sorted(unsupported_times)
def windows(directory, schema, cancelled=lambda: False, stats=None):
def windows(directory, schema, cancelled=lambda: False, stats=None, extend=True):
directory = Path(directory)
stats = stats if stats is not None else {}
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats)
wheel_times = [row[0] for row in wheels]
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0,
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
metadata, sensors, history, targets, boundaries, unsupported_times = prepare(directory, schema, stats, extend)
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
'staleSensorWindows': 0, 'boundaryWindows': 0, 'maxSensorAgeMs': 0, 'maxFrameAgeMs': 0,
'unsupportedWindows': 0, 'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
sensor_times = [row[0] for row in sensors]
target_times = [row[0] for row in targets]
available = np.zeros(SLOTS, dtype=np.float32)
for _, slot, _ in history:
available[slot] = float(schema[slot]['template']['type'] != 'drive')
available[:len(schema)] = 1
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
command_times = np.full(SLOTS, -np.inf)
cursor = 0
recent = collections.deque(maxlen=32)
last_sample, last_pts = -math.inf, -math.inf
last_pts = -math.inf
def frames():
nonlocal last_pts
files = metadata['video'].get('files', ['video.mkv'])
for name in files:
if Path(name).name != name:
@@ -141,59 +149,52 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
with av.open(str(directory / name)) as container:
stream = container.streams.video[0]
stream.codec_context.thread_count = 1
yield from container.decode(stream)
for frame in container.decode(stream):
if frame.pts is None:
raise ValueError('Video frame has no timestamp')
stamp = float(frame.pts * frame.time_base) * 1000
stats['videoFrames'] += 1
if math.isfinite(last_pts):
stats['maxVideoGapMs'] = max(stats['maxVideoGapMs'], stamp - last_pts)
last_pts = stamp
if not metadata['startedAt'] - 2000 <= stamp <= metadata['endedAt'] + 2000:
raise ValueError('Video PTS is not aligned to session Unix time')
yield frame
for frame in frames():
represented = set()
for stamp, frame, captured_at in decision_frames(frames()):
if cancelled():
return
if frame.pts is None:
raise ValueError('Video frame has no timestamp')
stamp = float(frame.pts * frame.time_base) * 1000
if not math.isfinite(stamp) or stamp < last_pts:
raise ValueError('Invalid/nonmonotonic video timestamps')
stats['videoFrames'] += 1
if math.isfinite(last_pts):
stats['maxVideoGapMs'] = max(stats['maxVideoGapMs'], stamp - last_pts)
last_pts = stamp
if not metadata['startedAt'] - 2000 <= stamp <= metadata['endedAt'] + 2000:
raise ValueError('Video PTS is not aligned to session Unix time')
if stamp < metadata.get('trainingStartedAt', metadata['startedAt']):
continue
if stamp - last_sample < 99:
continue
last_sample = stamp
while cursor < len(history) and history[cursor][0] < stamp:
when, slot, values = history[cursor]
command_values[slot] = values
command_times[slot] = when
# Do not let drive predictions simply copy earlier human/model drive commands.
if schema[slot]['template']['type'] != 'drive':
command_values[slot] = values
command_times[slot] = when
cursor += 1
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
sensor_index = bisect.bisect_left(sensor_times, stamp) - 1
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
stats['staleSensorWindows'] += 1
recent.clear()
continue
stats['maxSensorAgeMs'] = max(stats['maxSensorAgeMs'], stamp - sensor_times[sensor_index])
stats['maxFrameAgeMs'] = max(stats['maxFrameAgeMs'], stamp - captured_at)
seen = np.isfinite(command_times).astype(np.float32)
ages = np.minimum((stamp - command_times) / 10000, 1).astype(np.float32)
state = np.concatenate((sensors[sensor_index][1], command_values.flatten(), ages, seen))
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
recent.append((stamp, pixels, state))
chosen = []
for offset in (900, 600, 300, 0):
options = [row for row in recent if row[0] <= stamp - offset + 0.01]
if not options or stamp - offset - options[-1][0] > 200:
break
chosen.append(options[-1])
chosen = history_frames(recent, stamp)
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
continue
if bisect.bisect_right(unsupported_times, stamp + 100) > bisect.bisect_left(unsupported_times, chosen[0][0]):
stats['unsupportedWindows'] += 1
continue
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
stats['boundaryWindows'] += 1
continue
before = bisect.bisect_left(wheel_times, stamp) - 1
after = bisect.bisect_right(wheel_times, stamp + 100) - 1
if before < 0 or after < 0 or wheels[before][1] is None or wheels[after][1] is None:
stats['unknownWheelWindows'] += 1
continue
wheel_target, previous_wheels = wheels[after][1], wheels[before][1]
start = bisect.bisect_left(target_times, stamp)
end = bisect.bisect_left(target_times, stamp + 100)
actions = np.zeros(SLOTS, dtype=np.float32)
@@ -202,13 +203,8 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
families = set()
for _, slot, vector in targets[start:end]:
template = schema[slot]['template']
family = template['type']
if family == 'drive':
continue
if family == 'peripheral':
peripheral = template.get('peripheral') or {}
family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control'))
if ((actions[slot] and (not schema[slot]['fields'] or not np.array_equal(values[slot], vector)))
family = command_family(template)
if ((actions[slot] and (template['type'] in ('headlight', 'raw') or not np.array_equal(values[slot], vector)))
or (not actions[slot] and family in families)):
ambiguous = True
break
@@ -217,6 +213,11 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
if ambiguous:
stats['ambiguousWindows'] += 1
continue
represented.update(range(start, end))
stats['representedCommandEvents'] = len(represented)
for slot in np.flatnonzero(actions):
stats['commandEventCounts'][slot] += 1
stats['usableWindows'] += 1
stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available, wheel_target, previous_wheels
stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1
stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema)))
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available
@@ -13,8 +13,9 @@ import time
import av
import numpy as np
import torch
from observations import decision_frames, history_frames
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector
def main():
@@ -54,23 +55,16 @@ def main():
sensors, sensor_time = {}, 0
history = collections.deque(maxlen=32)
latched = set()
sampled = 0
with av.open(args.url, options={'rtsp_transport': 'tcp', 'timeout': '3000000',
'use_wallclock_as_timestamps': '1'}) as video:
stream = video.streams.video[0]
stream.codec_context.thread_count = 1
for frame in video.decode(stream):
for stamp, frame, captured_at in decision_frames(video.decode(stream)):
if closed.is_set():
return
if frame.pts is None:
continue
stamp = float(frame.pts * frame.time_base) * 1000
now = time.time() * 1000
if not math.isfinite(stamp) or abs(now - stamp) > 2000:
if abs(now - captured_at) > 2000:
raise ValueError('Video time is stale or not aligned to server time')
if stamp - sampled < 99:
continue
sampled = stamp
with mutex:
while observations and observations[0]['ts'] < stamp:
event = observations.popleft()
@@ -78,7 +72,7 @@ def main():
args.threshold = max(.05, min(.99, float(event['threshold'])))
elif event['kind'] == 'sensor':
sensors, sensor_time = event['data'], event['ts']
elif event['kind'] == 'command':
elif event['kind'] == 'command' and event['data'].get('type') != 'drive':
try:
encoded = encode_command(event['data'], schema)
if encoded:
@@ -94,68 +88,35 @@ def main():
np.isfinite(last_commands).astype(np.float32)))
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
history.append((stamp, pixels, state))
selected = []
for offset in (900, 600, 300, 0):
candidates = [entry for entry in history if entry[0] <= stamp - offset + .01]
if not candidates or stamp - offset - candidates[-1][0] > 200:
break
selected.append(candidates[-1])
selected = history_frames(history, stamp)
if len(selected) != 4:
continue
before = time.perf_counter()
with torch.inference_mode():
logits, numeric, wheel_output = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
logits, numeric = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
probabilities = logits[0].sigmoid().tolist()
predictions = numeric[0].tolist()
wheel_speeds = [round(value * 500) for value in wheel_output[0].tolist()]
if not all(math.isfinite(value) for value in probabilities):
raise ValueError('Nonfinite model output')
families = {}
active = set()
proposals = []
for slot, descriptor in enumerate(schema):
if descriptor['template']['type'] == 'drive':
continue
command = json.loads(json.dumps(descriptor['template']))
family = command['type']
if family == 'peripheral':
family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control'])
for index, field in enumerate(descriptor['fields']):
number = predictions[slot][index]
if not math.isfinite(number):
raise ValueError('Nonfinite control value')
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
target = command
for key in field['path'][:-1]:
target = target[key]
target[field['path'][-1]] = round(number) if field['integer'] else number
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command,
'reason': 'Below threshold'})
if probabilities[slot] < args.threshold:
continue
active.add(slot)
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
if family not in families or probabilities[slot] > families[family][0]:
families[family] = (probabilities[slot], command, slot, discrete)
winners = select_slots(probabilities, schema, args.threshold)
active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold}
latched.intersection_update(active)
# Choose the winning action before latching, so a held toggle cannot
# expose a competing lower-confidence toggle on the following tick.
commands = [command for _, command, slot, discrete in families.values()
if not discrete or slot not in latched]
winners = {row[2]: row for row in families.values()}
for proposal in proposals:
slot = proposal['slot']
if slot in active:
proposal['reason'] = ('Competing action' if slot not in winners else
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed')
for _, _, slot, discrete in families.values():
if discrete:
proposals, commands = [], []
for slot, descriptor in enumerate(schema):
command = decode_command(descriptor, predictions[slot])
discrete = command['type'] in ('headlight', 'raw')
reason = ('Below threshold' if slot not in active else
'Competing action' if slot not in winners else
'Held discrete action' if discrete and slot in latched else 'Proposed')
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason})
if reason == 'Proposed':
commands.append(command)
if slot in winners and discrete:
latched.add(slot)
commands.insert(0, {'type': 'drive', 'driveDirect': dict(zip(('left', 'right'), wheel_speeds))})
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
print(json.dumps({'kind': 'prediction', 'frameAt': captured_at, 'decisionAt': stamp, 'sensorAt': sensor_time,
'latencyMs': (time.perf_counter() - before) * 1000,
'wheelSpeeds': wheel_speeds, 'sensorPresent': sensor_vector(sensors)[32:].tolist(),
'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(),
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
@@ -0,0 +1,181 @@
"""Training objective and bounded, observational learning diagnostics."""
import copy
import time
import numpy as np
import torch
from torch.nn import functional as F
from policy import SLOTS, VALUES, select_slots, decode_command, is_stop
def loss_setup(samples, schema):
numeric_mask = torch.zeros(SLOTS, VALUES)
for slot, descriptor in enumerate(schema):
numeric_mask[slot, :len(descriptor['fields'])] = 1
positives = np.sum([sample[2] for sample in samples], axis=0)
opportunities = np.sum([sample[4] for sample in samples], axis=0)
weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
return numeric_mask, weight
def tensors(samples):
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*samples)]
return images.float().div_(255), states, targets, values, available
def losses(logits, predictions, targets, values, available, numeric_mask, positive_weight):
events = F.binary_cross_entropy_with_logits(logits, targets, pos_weight=positive_weight, reduction='none')
events = (events * available).sum() / available.sum().clamp_min(1)
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
parameters = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
return events, parameters
def distribution(values):
values = np.asarray(values, dtype=np.float64)
if not values.size:
return {'count': 0, 'mean': None, 'p50': None, 'p95': None, 'max': None}
return {'count': int(values.size), 'mean': float(values.mean()),
'p50': float(np.quantile(values, .5)), 'p95': float(np.quantile(values, .95)), 'max': float(values.max())}
def decisions(scores, schema, threshold):
selected = np.zeros_like(scores, dtype=bool)
for row, vector in enumerate(scores):
for slot in select_slots(vector, schema, threshold):
selected[row, slot] = True
return selected
def counts(predicted, actual):
matched = int((predicted & actual).sum())
proposed, wanted = int(predicted.sum()), int(actual.sum())
empty = ~actual.any(axis=1)
return {'predictedActions': proposed, 'positiveActions': wanted, 'matchedActions': matched,
'precision': matched / max(1, proposed), 'recall': matched / max(1, wanted),
'f1': 2 * matched / max(1, proposed + wanted),
'falsePositiveActions': proposed - matched,
'noCommandWindows': int(empty.sum()), 'unwantedCommandWindows': int((empty & predicted.any(axis=1)).sum())}
def summarize(scores, actual, schema, threshold, frequencies):
active_scores = scores[:, :len(schema)]
labels = actual[:, :len(schema)]
thresholds = sorted(set([.1, .3, .5, .7, .9, threshold]))
sweep = [{'threshold': value, **counts(decisions(scores, schema, value), actual)} for value in thresholds]
frequent = np.broadcast_to(frequencies, scores.shape)
baseline = [{'threshold': value, **counts(decisions(frequent, schema, value), actual)} for value in thresholds]
return {'scoreOnHumanCommands': distribution(active_scores[labels]),
'scoreOnAbsentCommands': distribution(active_scores[~labels]),
'maxScoreOnNoCommandWindows': distribution(active_scores[~actual.any(axis=1)].max(axis=1)) if (~actual.any(axis=1)).any() else distribution([]),
'thresholds': sweep, 'baselines': {'sendNothing': counts(np.zeros_like(actual), actual),
'trainingFrequency': baseline, 'frequencySource': 'Training windows only; no held-out fitting'},
'perCommandScores': [{'slot': slot, 'onHumanCommand': distribution(scores[actual[:, slot], slot]),
'otherwise': distribution(scores[~actual[:, slot], slot])} for slot in range(len(schema))]}
def evaluate(model, samples, schema, threshold, frequencies, batch_size, cancelled):
"""Same score selection as runtime; isolated windows cannot measure latching."""
model.eval()
score_rows, target_rows = [], []
value_error = value_count = 0
decoded = {}
with torch.inference_mode():
for offset in range(0, len(samples), batch_size):
if cancelled():
break
images, states, targets, values, _ = tensors(samples[offset:offset + batch_size])
logits, numeric = model(images, states)
if not torch.isfinite(logits).all() or not torch.isfinite(numeric).all():
raise ValueError('Nonfinite evaluation output')
score_rows.append(logits.sigmoid().numpy())
target_rows.append(targets.numpy().astype(bool))
for row, slot in targets.nonzero().tolist():
descriptor = schema[slot]
n = len(descriptor['fields'])
value_error += (numeric[row, slot, :n] - values[row, slot, :n]).square().sum().item()
value_count += n
prediction = decode_command(descriptor, numeric[row, slot])
expected = decode_command(descriptor, values[row, slot])
for field in descriptor['fields']:
a, b = prediction, expected
for key in field['path']:
a, b = a[key], b[key]
key = descriptor['template']['type'] + '.' + '.'.join(map(str, field['path']))
total, count = decoded.get(key, (0, 0))
decoded[key] = (total + abs(a - b), count + 1)
if not score_rows:
return {'windows': 0, 'complete': False}
scores, actual = np.concatenate(score_rows), np.concatenate(target_rows)
predicted = decisions(scores, schema, threshold)
stop_slots = [i for i, d in enumerate(schema) if is_stop(d)]
move_slots = [i for i, d in enumerate(schema) if d['template']['type'] == 'drive' and not is_stop(d)]
stop_count = int(actual[:, stop_slots].sum())
stop_match = int((actual[:, stop_slots] & predicted[:, stop_slots]).sum())
move_count = int(actual[:, move_slots].sum())
move_match = int((actual[:, move_slots] & predicted[:, move_slots]).sum())
return {'windows': len(scores), 'complete': len(scores) == len(samples),
'stopEvents': stop_count, 'missedStops': stop_count - stop_match,
'stopRecall': stop_match / max(1, stop_count),
'falseStops': int((predicted[:, stop_slots] & ~actual[:, stop_slots]).sum()),
'movementEvents': move_count, 'movementRecall': move_match / max(1, move_count),
'commands': [{'slot': i, 'command': d['template'], 'actual': int(actual[:, i].sum()),
'predicted': int(predicted[:, i].sum()),
'matched': int((actual[:, i] & predicted[:, i]).sum())} for i, d in enumerate(schema)],
'numericMse': value_error / max(1, value_count),
'decodedParameterMaeBeforeServerLimits': {key: total / count for key, (total, count) in decoded.items()},
**counts(decisions(scores, schema, threshold), actual),
**summarize(scores, actual, schema, threshold, frequencies)}
def probe(model, samples, schema, threshold, frequencies, cancelled, max_seconds=20):
"""Try fitting a small real training subset on a disposable copy, never publish it."""
started = time.monotonic()
# Include both event and quiet windows; source is training only. No random
# state/optimizer changes escape into the continuing learner.
quiet = [row for row in samples if not row[2].any()][:4]
# A reservoir is grouped by category: taking its first events could probe
# only zero-speed drive and falsely suggest all controls were learnable.
by_slot = [[i for i, row in enumerate(samples) if row[2][slot]] for slot in range(len(schema))]
chosen = []
for rank in range(8):
for indices in by_slot:
if rank < len(indices) and indices[rank] not in chosen and len(chosen) < 8:
chosen.append(indices[rank])
subset = [samples[i] for i in chosen] + quiet
if not chosen:
return {'status': 'No command examples', 'steps': 0}
with torch.random.fork_rng(devices=[]):
candidate = copy.deepcopy(model)
optimizer = torch.optim.AdamW(candidate.parameters(), lr=.001)
numeric_mask, weights = loss_setup(subset, schema)
images, states, targets, values, available = tensors(subset)
initial_loss = final_loss = gradient_norm = None
completed = 0
expired = lambda: cancelled() or time.monotonic() - started >= max_seconds
for _ in range(80):
if expired():
break
candidate.train()
optimizer.zero_grad(set_to_none=True)
logits, predictions = candidate(images, states)
event_loss, value_loss = losses(logits, predictions, targets, values, available, numeric_mask, weights)
loss = event_loss + value_loss
if not torch.isfinite(loss):
raise ValueError('Nonfinite learning probe loss')
if initial_loss is None:
initial_loss = loss.item()
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(candidate.parameters(), 1)
if not torch.isfinite(norm):
raise ValueError('Nonfinite learning probe gradient')
gradient_norm = float(norm)
optimizer.step()
final_loss = loss.item()
completed += 1
result = evaluate(candidate, subset, schema, threshold, frequencies, 16, cancelled)
return {'status': 'completed' if completed == 80 else 'budget limited', 'steps': completed,
'windows': len(subset), 'initialLoss': initial_loss, 'lastStepLoss': final_loss,
'lastGradientNormBeforeClipping': gradient_norm, 'elapsedSeconds': time.monotonic() - started,
'evaluation': result, 'meaning': 'Memorization on a disposable copy of training data, not generalization'}
@@ -0,0 +1,45 @@
"""Shared causal 10 Hz sampling for recorded and live video."""
import math
def decision_frames(frames):
"""Yield decision time, latest past image, and its actual capture timestamp.
Sampling every third/fourth decoded frame leaves holes at rates such as 25
FPS. A fixed grid covers each 100ms command interval exactly once. A frame
after the decision can release the pending tick, but is never its input.
"""
previous = None
previous_time = None
tick = None
for frame in frames:
if frame.pts is None:
raise ValueError('Video frame has no timestamp')
stamp = float(frame.pts * frame.time_base) * 1000
if not math.isfinite(stamp) or (previous_time is not None and stamp < previous_time):
raise ValueError('Invalid/nonmonotonic video timestamps')
if tick is None:
tick = stamp
while tick < stamp - .01:
# Do not synthesize observations over missing/stalled video. History
# selection will reject a temporal gap until fresh history exists.
if previous is not None and tick - previous_time <= 200:
yield tick, previous, previous_time
elif stamp - tick > 200:
tick += max(1, math.floor((stamp - tick) / 100)) * 100
continue
tick += 100
if abs(stamp - tick) <= .01:
yield tick, frame, stamp
tick += 100
previous, previous_time = frame, stamp
def history_frames(history, stamp):
selected = []
for offset in (900, 600, 300, 0):
candidates = [row for row in history if row[0] <= stamp - offset + .01]
if not candidates or stamp - offset - candidates[-1][0] > 200:
return []
selected.append(candidates[-1])
return selected
@@ -4,6 +4,8 @@ Slots describe command shapes, not arbitrary executable instructions. Numeric
leaves are regressed; string/bool/list values remain exact template constants.
No command is sent by this module. Execution must use server authorization.
"""
import base64
import itertools
import copy
import json
import math
@@ -12,12 +14,13 @@ import numpy as np
import torch
from torch import nn
VERSION = 2
VERSION = 5
SLOTS = 64
VALUES = 8
FRAMES = 4
HEIGHT, WIDTH = 120, 160
OPERATING_TYPES = {"drive", "motors", "servo", "headlight", "laser", "horn", "peripheral", "song", "raw"}
OPERATING_TYPES = {"drive", "servo", "headlight", "raw"}
MODE_COMMANDS = tuple(base64.b64encode(bytes([opcode])).decode() for opcode in (128, 131, 132, 143))
SENSORS = [
("bumpsAndWheelDrops.bumpLeft", 1), ("bumpsAndWheelDrops.bumpRight", 1),
("bumpsAndWheelDrops.wheelDropLeft", 1), ("bumpsAndWheelDrops.wheelDropRight", 1),
@@ -26,7 +29,6 @@ SENSORS = [
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
@@ -55,6 +57,10 @@ def sensor_vector(sensors):
def describe_command(command):
if command.get('type') not in OPERATING_TYPES:
return None
# Raw is restricted to the existing mode/dock buttons, not arbitrary OI
# packets that could reintroduce excluded actuators or songs.
if command['type'] == 'raw' and command.get('raw') not in MODE_COMMANDS:
return None
if len(json.dumps(command)) > 4096:
raise ValueError('Operating command exceeds 4096-byte schema limit')
values, fields = [], []
@@ -62,16 +68,17 @@ def describe_command(command):
def visit(value, path=()):
if isinstance(value, dict):
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
# Exact actuator zeros preserve demonstrated releases.
if value == 0:
return copy.deepcopy(value)
if isinstance(value, (int, float)) and not isinstance(value, bool):
if not math.isfinite(value) or len(values) >= VALUES:
raise ValueError('Nonfinite command or too many numeric control fields')
scale = {'drive': 500, 'motors': 255, 'servo': 180, 'peripheral': 255,
'horn': 1000, 'song': 127}.get(command['type'], 1)
fields.append({'path': list(path), 'scale': scale, 'integer': isinstance(value, int)})
scale = {'drive': 500, 'servo': 180}.get(command['type'], 1)
fields.append({'path': list(path), 'scale': scale, 'integer': command['type'] in ('drive', 'servo') or isinstance(value, int)})
values.append(normalize(value, scale))
return {'$number': len(values) - 1}
# Arrays (e.g. songs) are discrete exact demonstrations, not variable
# length regression outputs. Never reinterpret raw OI bytes as numbers.
# Mode bytes and named actions remain exact constants.
return copy.deepcopy(value)
template = visit(command)
@@ -89,7 +96,7 @@ def encode_command(command, schema, extend=False):
if not extend:
raise ValueError('Command shape missing from model schema')
if len(schema) >= SLOTS:
raise ValueError('64 command shapes exhausted; recording skipped, not silently truncated')
raise ValueError('64 command shapes exhausted; affected windows excluded')
slot = len(schema)
schema.append(descriptor)
vector = np.zeros(VALUES, dtype=np.float32)
@@ -97,6 +104,59 @@ def encode_command(command, schema, extend=False):
return slot, vector
def initial_schema():
# Match the retained commandPipeline.js controls. Values describe fields,
# not artificial demonstrations; zero-speed drive remains a drive command.
commands = []
for left, right in itertools.product((0, 1), repeat=2):
commands.append({'type': 'drive', 'driveDirect': {'left': left, 'right': right}})
for angle in (0, 1):
commands.append({'type': 'servo', 'servo': {'angle': angle}})
for action in ('on', 'off', 'toggle'):
commands.append({'type': 'headlight', 'headlight': {'action': action}})
for raw in MODE_COMMANDS:
commands.append({'type': 'raw', 'raw': raw})
schema = []
for command in commands:
encode_command(command, schema, extend=True)
return schema
def command_family(command):
return (command['type'],)
def select_slots(scores, schema, threshold):
winners = {}
for slot, descriptor in enumerate(schema):
if scores[slot] < threshold:
continue
family = command_family(descriptor['template'])
if family not in winners or scores[slot] > scores[winners[family]]:
winners[family] = slot
return set(winners.values())
def decode_command(descriptor, values):
command = copy.deepcopy(descriptor['template'])
for index, field in enumerate(descriptor['fields']):
number = float(values[index])
if not math.isfinite(number):
raise ValueError('Nonfinite control value')
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
target = command
for key in field['path'][:-1]:
target = target[key]
target[field['path'][-1]] = round(number) if field['integer'] else number
return command
def is_stop(descriptor):
command = descriptor['template']
direct = command.get('driveDirect') or {}
return command['type'] == 'drive' and direct.get('left') == 0 and direct.get('right') == 0
class Policy(nn.Module):
def __init__(self):
super().__init__()
@@ -109,7 +169,6 @@ class Policy(nn.Module):
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
)
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
self.wheels = nn.Linear(128, 2)
self.actions = nn.Linear(128, SLOTS)
self.values = nn.Linear(128, SLOTS * VALUES)
@@ -118,17 +177,16 @@ class Policy(nn.Module):
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
history, _ = self.history(torch.cat((encoded, state), dim=-1))
last = history[:, -1]
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh(), self.wheels(last).tanh()
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh()
def specification(schema):
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2',
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v5',
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
'historyStrideMs': 300, 'decisionIntervalMs': 100,
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms',
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'],
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state',
'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants',
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
@@ -39,13 +39,13 @@ def main():
import numpy as np
import av
import torch
from torch.nn import functional as functional
from learning import loss_setup, tensors, losses as policy_losses, evaluate, probe
from dataset import windows
from policy import Policy, VERSION, SLOTS, VALUES, specification
from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots, initial_schema
if args.check_dependencies:
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
parameters=sum(p.numel() for p in Policy().parameters()), specification=specification([]))
parameters=sum(p.numel() for p in Policy().parameters()), specification=specification(initial_schema()))
return
if not args.job:
parser.error('--job is required unless --check-dependencies is used')
@@ -70,13 +70,15 @@ def main():
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
model = Policy().cpu()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
# A fresh training lineage prevents previously trained footage leaking into validation.
resume_path = root / 'training' / 'resume-v3.pt'
schema, steps, examples, last_published = [], 0, 0, 0
# The command-event policy has different inputs, targets and weights. Keep its
# optimizer lineage separate; reuse recordings with their unchanged partition.
resume_path = root / 'training' / 'resume-v6.pt'
schema, steps, examples, last_published = initial_schema(), 0, 0, 0
trained_rovers = set()
control_profiles = []
distinct_windows = {}
published_windows = 0
last_probe_steps, learning_probe = 0, None
if resume_path.exists():
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
if saved['version'] != VERSION:
@@ -90,6 +92,8 @@ def main():
control_profiles = saved.get('controlProfiles', [])
distinct_windows = saved.get('distinctWindows', {})
published_windows = saved.get('publishedWindows', 0)
last_probe_steps = saved.get('lastProbeSteps', 0)
learning_probe = saved.get('learningProbe')
cpu_started = time.process_time()
decode_started = time.monotonic()
dataset_stats = []
@@ -107,25 +111,24 @@ def main():
candidate_schema = copy.deepcopy(schema)
samples, seen = [], 0
stats = {}
buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')}
buckets = {key: [] for key in ('stop', 'command', 'none')}
counts = dict.fromkeys(buckets, 0)
held_out = is_held_out(session_id)
try:
directory = root / 'recordings' / session_id
for sample in windows(directory, candidate_schema, cancelled, stats):
for sample in windows(directory, candidate_schema, cancelled, stats, extend=not held_out):
seen += 1
# Keep evaluation naturally distributed. Training reserves space
# for ordinary turns/reversing so idle or straight motion cannot
# consume the entire sample budget. No scripted driving required.
# for recorded commands and releases so periods without commands
# cannot consume the entire sample budget. No scripted driving required.
if held_out:
reservoir, count, capacity = samples, seen, per_session
else:
left, right = sample[5]
category = ('stopped' if max(abs(left), abs(right)) <= .02 else
'turn' if abs(left - right) > .1 else
'reverse' if left + right < 0 else 'forward')
category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
for slot, descriptor in enumerate(candidate_schema)) else
'command' if sample[2].any() else 'none')
counts[category] += 1
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 4)
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 3)
if len(reservoir) < capacity:
reservoir.append(sample)
else:
@@ -137,15 +140,15 @@ def main():
if cancelled():
break # Do not call a partially decoded recording validated.
if not samples:
if held_out and stats.get('unsupportedCommandEvents'):
dataset_stats.append({**stats, 'partition': 'heldOut', 'sampledWindows': 0,
'skipReason': 'No supported aligned windows'})
continue
raise ValueError('No complete, aligned observation/action windows')
# Entire sessions stay in one partition across all jobs. Neighboring
# video windows must never straddle training and validation.
held_out = is_held_out(session_id)
if held_out:
if candidate_schema != schema:
dataset_stats.append({**stats, 'partition': 'heldOut', 'sampledWindows': len(samples),
'skipReason': 'Vocabulary absent from training data'})
continue # Retry after training data introduces these command shapes.
validation.extend(samples)
else:
schema = candidate_schema
@@ -164,22 +167,23 @@ def main():
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
rejected[session_id] = str(error)[:500]
emit('skipped', sessionId=session_id, reason=rejected[session_id])
if len(pool) < config['minimumSamples'] or not any(np.max(np.abs(sample[5])) > .02 for sample in pool) or cancelled():
if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled():
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
return
decode_seconds = time.monotonic() - decode_started
audit_started = time.monotonic()
numeric_mask, positive_weight = loss_setup(pool, schema)
training_rows = [row for row in dataset_stats if row.get('partition') == 'training']
natural_count = sum(row.get('usableWindows', 0) for row in training_rows)
frequencies = np.sum([row.get('commandEventCounts', [0] * SLOTS) for row in training_rows], axis=0) / max(1, natural_count)
audit_subset = pool[::max(1, len(pool) // 96)][:96]
before_training = evaluate(model, audit_subset, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
before_audit_seconds = time.monotonic() - audit_started
train_started = time.monotonic()
numeric_mask = torch.zeros(SLOTS, VALUES)
for slot, descriptor in enumerate(schema):
numeric_mask[slot, :len(descriptor['fields'])] = 1
# Balance sparse events without assuming that every time step should fire
# every control. This is training loss, not a driving-quality evaluation.
positives = np.sum([sample[2] for sample in pool], axis=0)
opportunities = np.sum([sample[4] for sample in pool], axis=0)
positive_weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
gradient_norms = []
losses = []
model.train()
for _ in range(config['passesPerJob']):
@@ -188,28 +192,25 @@ def main():
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
break
batch = pool[offset:offset + config['batchSize']]
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
images = images.float().div_(255)
images, states, targets, values, available = tensors(batch)
optimizer.zero_grad(set_to_none=True)
action_logits, predictions, wheel_predictions = model(images, states)
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
pos_weight=positive_weight, reduction='none')
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets)
loss = wheel_loss + event_loss + value_loss
action_logits, predictions = model(images, states)
event_loss, value_loss = policy_losses(action_logits, predictions, targets, values, available, numeric_mask, positive_weight)
loss = event_loss + value_loss
if not torch.isfinite(loss):
raise ValueError('Nonfinite training loss; checkpoint not saved')
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
gradient_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
if not torch.isfinite(gradient_norm):
raise ValueError('Nonfinite training gradient; checkpoint not saved')
gradient_norms.append(float(gradient_norm))
optimizer.step()
steps += 1
examples += len(batch)
losses.append(loss.item())
if steps % 10 == 0:
emit('progress', steps=steps, examples=examples, loss=loss.item(),
losses={'wheels': wheel_loss.item(), 'events': event_loss.item(), 'accessoryValues': value_loss.item()})
losses={'events': event_loss.item(), 'commandValues': value_loss.item()})
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
break
if steps == initial_steps:
@@ -220,65 +221,65 @@ def main():
evaluation_started = time.monotonic()
distinct_windows.update(observed_windows)
unique_count = sum(distinct_windows.values())
evaluation = {'status': 'waiting for held-out sessions', 'windows': len(validation)}
coverage = [row for row in dataset_stats if row.get('partition') == 'heldOut']
unsupported_events = sum(row.get('unsupportedCommandEvents', 0) for row in coverage)
unsupported_windows = sum(row.get('unsupportedWindows', 0) for row in coverage)
evaluation = {'status': 'No supported held-out windows' if unsupported_events and not validation else 'waiting for held-out sessions',
'windows': len(validation), 'unsupportedCommandEvents': unsupported_events,
'unsupportedWindows': unsupported_windows}
if validation and not cancelled():
model.eval()
true_positive = predicted_positive = actual_positive = 0
value_error = value_count = 0
evaluated = 0
wheel_error = stopped_error = persistence_error = 0
changed_error = changed_persistence = changed_count = moving_count = 0
with torch.inference_mode():
for offset in range(0, len(validation), config['batchSize']):
if cancelled():
break
batch = validation[offset:offset + config['batchSize']]
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
logits, predictions, wheel_predictions = model(images.float() / 255, states)
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool()
actual = targets.bool() & available.bool()
true_positive += (predicted & actual).sum().item()
predicted_positive += predicted.sum().item()
actual_positive += actual.sum().item()
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
value_error += ((predictions - values).square() * mask).sum().item()
value_count += mask.sum().item()
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02
changed_error += errors[changed].sum().item()
changed_persistence += persistence[changed].sum().item()
changed_count += changed.sum().item()
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item()
wheel_error += errors.sum().item()
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500
persistence_error += persistence.sum().item()
evaluated += len(batch)
precision = true_positive / max(1, predicted_positive)
recall = true_positive / max(1, actual_positive)
mse = value_error / max(1, value_count)
wheel_mae = wheel_error / max(1, evaluated)
stopped_mae = stopped_error / max(1, evaluated)
persistence_mae = persistence_error / max(1, evaluated)
changed_mae = changed_error / max(1, changed_count)
changed_baseline = changed_persistence / max(1, changed_count)
# Check control changes separately: copying the previous speed can look
# excellent on long straight runs without learning to steer or stop.
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32
and changed_count >= 32 and wheel_mae < stopped_mae
and wheel_mae <= persistence_mae and changed_mae < changed_baseline)
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else
actual_positive >= 32 and precision >= .5 and recall >= .5)
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1)
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met',
'windows': evaluated, 'precision': precision, 'recall': recall,
'numericMse': mse, 'positiveActions': actual_positive,
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae,
'previousSpeedBaselineMaeMmPerSecond': persistence_mae,
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline,
'changedWindows': changed_count, 'movingWindows': moving_count,
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass}
evaluation_seconds = time.monotonic() - evaluation_started
measured = evaluate(model, validation, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
evaluated = measured['windows']
precision, recall = measured.get('precision', 0), measured.get('recall', 0)
actual_positive = measured.get('positiveActions', 0)
mse = measured.get('numericMse', 0)
stop_actual = measured.get('stopEvents', 0)
stop_recall = measured.get('stopRecall', 0)
drive_actual = measured.get('movementEvents', 0)
drive_recall = measured.get('movementRecall', 0)
frequency_f1 = max((row['f1'] for row in measured.get('baselines', {}).get('trainingFrequency', [])), default=0)
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
gate_reasons = []
for condition, reason in [
(unsupported_events == 0, 'Held-out controls are not fully represented; see unsupportedCommands'),
(evaluated == len(validation), 'Evaluation interrupted'),
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
(evaluated >= 128, 'Need 128 held-out windows'),
(actual_positive >= 32, 'Need 32 held-out command events'),
(stop_actual >= 8, 'Need eight held-out stop events'),
(drive_actual >= 16, 'Need 16 held-out movement commands'),
(drive_recall >= .5, 'Movement command recall below 0.5'),
(precision >= .5, 'Command precision below 0.5'),
(recall >= .5, 'Command recall below 0.5'),
(stop_recall >= .5, 'Stop recall below 0.5'),
(measured.get('f1', 0) > frequency_f1, 'Command F1 does not beat the training-frequency baseline'),
(mse <= .1, 'Command parameter error above 0.1'),
]:
if not condition:
gate_reasons.append(reason)
evaluation = {**measured, 'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
'unsupportedCommandEvents': unsupported_events, 'unsupportedWindows': unsupported_windows,
'method': 'Isolated held-out 100ms events; live selection, no temporal latch/physical rollout',
'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions,
'gateReasons': gate_reasons}
after_training = evaluate(model, audit_subset, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
probe_seconds = 0
if not cancelled() and (learning_probe is None or steps - last_probe_steps >= 1000):
remaining = config['maxJobSeconds'] - (time.monotonic() - started)
if remaining > 10:
probe_started = time.monotonic()
learning_probe = probe(model, audit_subset, schema, job['actionThreshold'], frequencies,
cancelled, max_seconds=min(20, remaining / 2))
learning_probe['atTrainingStep'] = steps
last_probe_steps = steps
probe_seconds = time.monotonic() - probe_started
training_audit = {'before': before_training, 'after': after_training,
'gradientNormBeforeClipping': {'min': min(gradient_norms), 'max': max(gradient_norms)},
'positiveWeights': positive_weight[:len(schema)].tolist(),
'naturalTrainingCommandFrequency': frequencies[:len(schema)].tolist(),
'sampledCommandFrequency': np.mean([row[2] for row in pool], axis=0)[:len(schema)].tolist(),
'learningProbe': learning_probe}
evaluation_seconds = time.monotonic() - evaluation_started - probe_seconds
publication = {'distinctMinutes': unique_count / 600,
'newMinutes': (unique_count - published_windows) / 600,
'minimumMinutes': config['minimumDrivingMinutes'],
@@ -331,9 +332,9 @@ def main():
'schema': schema, 'steps': steps, 'examples': examples,
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
'controlProfiles': control_profiles, 'distinctWindows': distinct_windows,
'publishedWindows': published_windows,
'publishedWindows': published_windows, 'lastProbeSteps': last_probe_steps, 'learningProbe': learning_probe,
}, resume_path)
diagnostics = {'dataset': dataset_stats, 'decodeSeconds': decode_seconds,
diagnostics = {'dataset': dataset_stats, 'trainingAudit': training_audit, 'probeSeconds': probe_seconds, 'beforeAuditSeconds': before_audit_seconds, 'decodeSeconds': decode_seconds,
'trainingSeconds': training_seconds, 'evaluationSeconds': evaluation_seconds, 'checkpointSeconds': time.monotonic() - save_started,
'cpuSeconds': time.process_time() - cpu_started,
'peakRssMiB': resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
@@ -274,6 +274,7 @@ odometerService.odometerEvents.on('update', ({ roverId, odometer }) => {
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
module.exports = {
stopDockGuard,
upsertRover,
removeRover,
lockRover,
@@ -1,3 +1,4 @@
const { getOperatingMode } = require('../operatingModeService');
// rover Manager private access
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
@@ -80,6 +81,7 @@ function createPrivateAccessPolicy(deps) {
allowClosedPrivateGrantInLockdown = false,
allowClosedPrivateCurrentDriver = false,
} = options;
if (getOperatingMode(socket) !== 'rover') return 'Leave PTZ before requesting rover control';
if (!record) return 'Unknown rover';
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
if (record.locked && !isAdmin(socket)) return 'Rover locked';
@@ -117,6 +117,7 @@ function createRosterLifecycle(deps) {
offline, while removeRoverDrivers still receives the captured record so
it can clean the reverse membership index and Socket.IO room membership.
*/
require('../dockingService').disconnect(record);
rovers.delete(id);
removeRoverDrivers(id, record);
stopDockGuard(id);
@@ -389,6 +389,10 @@ function createSensorPipeline(deps) {
function evaluateDockGuard(record, sensors) {
if (!record || !sensors) return;
if (sensors.docking?.phase === 'undocking' || sensors.docking?.phase === 'assisting') {
stopDockGuard(record.id);
return;
}
const state = getDockGuardState(record.id);
const now = Date.now();
const docked = Boolean(sensors?.chargingSources?.homeBase);
@@ -564,6 +568,10 @@ function createSensorPipeline(deps) {
};
record.lastSensor = { raw: frame, decoded };
}
if (decoded) {
decoded = { ...decoded, docking: require('../dockingService').processTelemetry(record, decoded) };
record.lastSensor = { raw: frame, decoded };
}
/*
Rover manager remains responsible only for decoding and routing sensor
frames. The dedicated service receives the completed sensor object after
@@ -577,6 +585,7 @@ function createSensorPipeline(deps) {
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
? processOvercurrentTelemetry(roverId, decoded)
: null;
record.lastOvercurrentProtection = overcurrentProtection;
updateMovement(record, decoded);
const hasDockInfo = decoded?.chargingSources != null;
if (hasDockInfo) {
@@ -589,6 +598,10 @@ function createSensorPipeline(deps) {
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded);
if (decoded) {
decoded = { ...decoded, docking: require('../dockingService').snapshot(record) };
record.lastSensor = { raw: frame, decoded };
}
io.to(record.room).volatile.emit('sensorFrame', {
roverId,
frame,
+33 -30
View File
@@ -5,16 +5,18 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('sessionService');
const { getFeatureFlags, configurationEvents } = require('../../configuration');
const { getRole, isAdmin, roleEvents } = require('../roleService');
const { getOperatingMode, setOperatingMode, operatingModeEvents } = require('../operatingModeService');
const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const {
getPublicState: getPtzCameraState,
getChatTargetForSocket: getPtzChatTargetForSocket,
PTZ_CAMERA_ID,
getOperatingModeDisplay,
getParticipantSocketIds,
ptzCameraEvents,
} = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
@@ -111,18 +113,13 @@ function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
function countControllableUsers(userEntries = []) {
const ids = new Set();
const ptzParticipants = new Set(getParticipantSocketIds());
userEntries.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const socketId = String(entry?.socketId || '').trim();
const roverId = String(entry?.roverId || '').trim();
/*
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
id and normal drivers to their physical rover. Counting entries after that
normalization gives the browser the same conceptual "controllable users"
count it shows in the user/queue panels without duplicating PTZ UI logic.
*/
if (socketId && roverId) ids.add(socketId);
if (socketId && (roverId || ptzParticipants.has(socketId))) ids.add(socketId);
});
return ids.size;
}
@@ -146,19 +143,14 @@ function buildUserEntry(socket) {
&& roverManager.isDriver(assignment.roverId, socket)
? assignment.roverId
: null;
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
return {
socketId: socket.id,
userId: socket?.data?.userId || null,
nickname: getNickname(socket) || null,
role,
/*
PTZ is not inserted into the physical rover roster, but for chat and user
presence it should read like the user moved to a rover-like target. Prefer
the PTZ chat target while the socket is queued or operating so presence,
queue lookup, and chat identity all agree.
*/
roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
operatingMode: getOperatingMode(socket),
operatingModeDisplay: getOperatingModeDisplay(socket.id),
roverId: verifiedPrimaryRover || verifiedAssignmentRover || null,
};
}
@@ -171,15 +163,7 @@ function buildSession(socket) {
const controllableUserCount = countControllableUsers(userEntries);
const users = userEntries.map((entry) => ({
...entry,
/*
PTZ is intentionally not a roverManager record, so the normal physical
rover visibility filter would erase the user's PTZ chat target. Preserve
it here because getPtzChatTargetForSocket already applied the PTZ access
and queue/operator rules before buildUserEntry returned it.
*/
roverId: entry.roverId === PTZ_CAMERA_ID
? entry.roverId
: filterVisibleRoverId(socket, entry.roverId),
roverId: filterVisibleRoverId(socket, entry.roverId),
}));
const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || '');
@@ -201,6 +185,7 @@ function buildSession(socket) {
return {
socketId: socket?.id || null,
role: getRole(socket),
operatingMode: getOperatingMode(socket),
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
@@ -210,15 +195,24 @@ function buildSession(socket) {
of navigation and layout without maintaining another feature registry.
*/
features,
roster,
roster: roster.map((rover) => ({
...rover,
permissions: { canControl: getRole(socket) !== 'spectator' && canDrive(rover.id, socket) },
turn: buildRoverTurn({
rover,
mode: getMode(),
socketId: socket?.id,
turnInfo: turnQueues[rover.id],
activeDriverId: activeDrivers[rover.id],
users,
}),
})),
odometers: roverManager.getOdometersForSocket(socket),
assignment: {
...assignment,
roverId: assignmentRoverId,
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
},
activeDrivers,
turnQueues,
roomCameras: getRoomCameras(),
ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(),
@@ -280,10 +274,19 @@ function syncAll() {
}
io.on('connection', (socket) => {
socket.on('session:setOperatingMode', async ({ operatingMode } = {}, cb = () => {}) => {
try {
cb({ ok: true, ...await setOperatingMode(socket, operatingMode) });
} catch (err) {
cb({ error: err.message });
}
});
logger.info('New socket connected', socket.id);
syncSocket(socket);
});
operatingModeEvents.on('change', () => syncAll());
roleEvents.on('change', ({ socket }) => {
if (!socket) return;
logger.info('Role changed; syncing session', socket.id);
@@ -0,0 +1,39 @@
const { describeQueue } = require('./queueDisplay');
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
const labels = {
...require('./labels'),
waiting: 'Someone else is driving',
active: 'You’re driving',
showName: 'Show rover name',
hideName: 'Hide rover name',
};
// Build this from visibility-filtered session data so private rover state stays private.
function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users }) {
const queue = turnInfo?.queue || [];
// Direct ownership can arrive before queue details on load/reconnect.
const currentDriverId = activeDriverId || turnInfo?.current || null;
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
const userIndex = socketId ? queue.indexOf(socketId) : -1;
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
const display = describeQueue(queue, currentDriverId, users);
return {
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
...display,
// Null distinguishes a rover that has never had a queue from an empty queue.
queue: turnInfo ? display.queue : null,
enabled,
ownsControl: Boolean(socketId && currentDriverId === socketId),
turnsAhead,
queueLength: queue.length,
deadline: turnInfo?.deadline || null,
durationMs: TURN_DURATION_MS,
idleDeadline: turnInfo?.idleDeadline || null,
idleGraceMs: IDLE_TIMEOUT_MS,
labels,
};
}
module.exports = { buildRoverTurn };
+1
View File
@@ -397,6 +397,7 @@ function advanceTurn(roverId) {
return;
}
if (queue.queue.length === 0) {
queue.current = null;
stopRover(roverId);
clearTurnTimer(roverId, queue);
setActiveDriver(roverId, null);
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
handoff: 'It’s your turn!',
idleWarning: 'Start driving or your turn will be skipped',
activeTimer: 'Your turn',
waitingTimer: 'Waiting',
untilTurn: 'until your turn',
timeRemaining: 'left',
showTimer: 'Show turn timer',
hideTimer: 'Hide turn timer',
};
@@ -0,0 +1,20 @@
// Queue entries are complete display records. No browser-side user lookup or
// naming fallback is needed, including when the queue comes from another server.
function describeQueue(socketIds, currentId, users) {
const byId = new Map(users.map((user) => [user.socketId, user]));
const currentIndex = currentId ? socketIds.indexOf(currentId) : -1;
return {
queue: socketIds.map((socketId) => {
const user = byId.get(socketId);
return {
socketId,
name: user?.nickname || user?.name || socketId.slice(0, 6) || 'unknown',
role: user?.role || null,
};
}),
currentId,
nextId: socketIds.length > 1 ? socketIds[(currentIndex + 1) % socketIds.length] : null,
};
}
module.exports = { describeQueue };
@@ -157,11 +157,11 @@ function identifySocket(socket, payload = {}) {
}
/*
Spectator-style pages carry identity too, but only the driver surface
participates in duplicate-driver enforcement. This flag remains on the
Spectator-style pages carry identity too, but only the driver and PTZ surfaces
participate in duplicate-driver enforcement. This flag remains on the
socket because it is connection-specific, not person-specific.
*/
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
socket.data.identitySurface = ['driver', 'ptz'].includes(payload.identitySurface) ? payload.identitySurface : 'passive';
const result = identifyCanonicalSocket(socket, {
...payload,
@@ -207,7 +207,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
if (
!currentSocket?.id ||
!currentUserId ||
currentSocket.data?.identitySurface !== 'driver' ||
!['driver', 'ptz'].includes(currentSocket.data?.identitySurface) ||
!enforceForCurrentSocket
) {
return;
@@ -215,7 +215,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
if (candidate?.data?.identitySurface !== 'driver') return false;
if (!['driver', 'ptz'].includes(candidate?.data?.identitySurface)) return false;
return getUserIdForSocket(candidate) === currentUserId;
});
+3 -14
View File
@@ -3,20 +3,9 @@
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
```
1. add new discord channel: live status
1. one concise message which contains the status of all the rovers
2. bot clears the channel, then sends a new message
3. has a fancy big embed that shows way more stuff
4. also has a small text copy at the top (readable on smartwatch lol)
1. ```
freaky: docked
wall-e: user1 driving
bweeble: NEEDS HELP
```
2. setting to disable replay popups in spectator settings menu
3. add admin ui for VIP and private requests instead of only through discord
4. make roverd self update checkout to main branch
5. fix this:
1. add admin ui for VIP and private requests instead of only through discord
2. make roverd self update checkout to main branch
3. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
@@ -132,11 +132,11 @@ export function ChatIdentity({ message, toolsToggle = null }) {
bot
</span>
) : null}
{message.roverId && (
{(message.roverId || message.operatingModeDisplay) && (
<RoverLabel
roverId={message.roverId}
name={message.roverName}
color={message.roverColor}
name={message.operatingModeDisplay?.name || message.roverName}
color={message.operatingModeDisplay?.color || message.roverColor}
fallback={message.roverId}
className="shrink-0 text-[0.7rem]"
/>
+5 -19
View File
@@ -51,33 +51,19 @@ function resolveTtsSettings(settings) {
function useChatComposerSessionState(allowSpectatorInput) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const chatTargetId = useMemo(() => {
/*
PTZ is intentionally not a roster entry, but session users expose the
current chat target for each socket. Prefer the self user entry so the TTS
control follows PTZ queue/operation state instead of only physical rover
assignment state.
*/
const self = users.find((entry) => entry?.socketId === socketId);
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
return self?.roverId || currentRoverId || null;
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
const rover = useMemo(
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
[chatTargetId, roster],
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
const ptzTtsSupported = Boolean(
ptz?.id &&
String(chatTargetId) === String(ptz.id) &&
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
);
return {
@@ -11,10 +11,10 @@ export function deriveDriveDockStateFromTelemetry(dockTelemetry) {
const oiNormalized = oiLabel.toLowerCase();
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
const docked = Boolean(dockTelemetry?.homeBase);
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
const driving = oiNormalized === 'full';
const charging = dockTelemetry?.dockingPhase === 'charging';
const driving = oiNormalized === 'full' && !dockTelemetry?.movementLocked;
const dockedNotCharging = docked && !charging;
const dockingInProgress = !docked && !charging && oiNormalized === 'passive';
const dockingInProgress = !docked && !charging && oiNormalized === 'passive' && dockTelemetry?.dockingPhase !== 'undocking';
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
}
+15 -44
View File
@@ -1,13 +1,12 @@
// Drive Dock Action
// Purpose: Defines the Drive Dock Action module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import { useEffect, useState } from 'react';
import '../MobileControls/mobileControls.css';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
import ControlHint from '../ControlHint/index.jsx';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
@@ -91,14 +90,13 @@ export default function DriveDockAction({
const isMobile = layout === 'mobile';
const roverId = useControlSelector((control) => control.state.roverId);
const actions = useControlActions();
const dockAssist = useManualDockAssist();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
const { driving, docked, charging, dockingInProgress } = state;
const [pending, setPending] = useState(null);
const pending = dockTelemetry.dockingPhase === 'undocking' ? 'drive' : null;
const [confirmOpen, setConfirmOpen] = useState(false);
const [showModal, setShowModal] = useState(false);
const manualAssistActive = Boolean(dockAssist.active);
const manualAssistActive = Boolean(dockTelemetry.cameraLocked);
const driveDisabled = !roverId || pending !== null;
const dockDisabled = !roverId || pending !== null;
@@ -124,64 +122,37 @@ export default function DriveDockAction({
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
const handleReturnToDrive = async () => {
if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button');
setPending('drive');
try {
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
};
const handleStartDrive = async () => {
const handleStartDrive = () => {
if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button');
setConfirmOpen(false);
setShowModal(false);
setPending('drive');
try {
dockAssist.exitAssist();
actions.setMode('drive');
await actions.runMacro('drive-sequence');
} catch (err) {
alert(err.message);
} finally {
setPending(null);
}
actions.undock();
};
const handleConfirmDock = async () => {
const handleConfirmDock = () => {
if (!roverId || pending) return;
if (isMobile) triggerTouchHaptic('button');
setPending('dock');
try {
dockAssist.enterAssist();
} catch (err) {
alert(err.message);
} finally {
setPending(null);
setConfirmOpen(false);
setShowModal(false);
}
actions.sendCommand('enterDocking');
setConfirmOpen(false);
setShowModal(false);
};
const handleOpenDock = () => {
if (dockDisabled) return;
if (isMobile) triggerTouchHaptic('button');
if (manualAssistActive) {
dockAssist.exitAssist();
actions.sendCommand('cancelDocking');
return;
}
setShowModal(true);
setConfirmOpen(true);
};
useEffect(() => {
if (dockTelemetry.dockingError) alert(dockTelemetry.dockingError);
}, [dockTelemetry.dockingError]);
// The drive/dock card appears inside the mobile controls and can be held or
// tapped repeatedly; attach mobile touch suppression directly to the card so
// Safari does not select labels or open callouts during those interactions.
@@ -254,7 +225,7 @@ export default function DriveDockAction({
<button
type="button"
disabled={driveDisabled}
onClick={handleReturnToDrive}
onClick={handleStartDrive}
onContextMenu={(event) => event.preventDefault()}
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
>
+1 -1
View File
@@ -48,7 +48,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
mobileHud={mobileHud}
labelScale={1}
/>
<ManualDockAssistOverlay mobileHud={mobileHud} />
<ManualDockAssistOverlay roverId={roverId} mobileHud={mobileHud} />
<HudChatInput compact={mobileHud} />
<OvercurrentOverlay compact={mobileHud} />
<LowBatteryOverlay compact={mobileHud} />
@@ -56,7 +56,7 @@ export const ACTIONS = [
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'sideReverse', label: 'Side reverse toggle', kind: 'button', section: 'Brush toggles' },
{ id: 'driveMacro', label: 'Drive / undock sequence', kind: 'button', section: 'Mode controls' },
{ id: 'driveMacro', label: 'Drive / undock', kind: 'button', section: 'Mode controls' },
{ id: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
{ id: 'laserToggle', label: 'Laser toggle', kind: 'button', section: 'Camera' },
@@ -24,8 +24,9 @@ function ActivityTile({ entity, connected, allowed, admin }) {
// Translucent fills preserve text contrast; an amber border still identifies
// locked tiles regardless of the admin's chosen color.
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
const Control = controls[entity.type] || ReadOnlyControl;
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
// Ordinary users see only the value; admins retain the entity's actual input.
const Control = entity.readOnly && !admin ? ReadOnlyControl : controls[entity.type] || ReadOnlyControl;
const disabled = !connected || !entity.available || !allowed || ((entity.locked || entity.readOnly) && !admin);
return <div className="flex min-w-0 flex-col gap-0.5 rounded-sm border px-0.5 py-0.5"
style={{ borderColor: entity.locked ? '#b45309' : `${color}aa`, backgroundColor: `${color}33` }}>
<div className="-mx-0.5 -mt-0.5 flex min-h-5 min-w-0 items-center gap-0.5 rounded-t px-0.5 py-0.5 text-white" style={{ backgroundColor: `${color}66` }}>
@@ -27,9 +27,8 @@ function detectSafari() {
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const users = useSessionSelector((state) => state.session?.users || []);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
@@ -49,26 +48,14 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const chatTargetId = useMemo(() => {
/*
HUD chat does not render the full composer controls, but it still sends
TTS payloads when the active target supports speech. PTZ appears only in
the session user target, not the physical rover roster, so use the same
self-target resolution as the full chat panel.
*/
const self = users.find((entry) => entry?.socketId === socketId);
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
return self?.roverId || currentRoverId || null;
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
const rover = useMemo(
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
[chatTargetId, roverRoster],
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roverRoster],
);
const ptzTtsSupported = Boolean(
ptz?.id &&
String(chatTargetId) === String(ptz.id) &&
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
const ttsPayload = useMemo(() => {
@@ -40,10 +40,8 @@ function HudOverlay({
});
const derivedDriverLabel = useSessionSelector((state) => {
if (!effectiveRoverId || variant !== 'spectator') return null;
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
const users = state.session?.users || [];
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
const turn = state.session?.roster?.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
return turn?.queue?.find((entry) => entry.socketId === turn.currentId)?.name || null;
});
const resolvedSensors = sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;

Some files were not shown because too many files have changed in this diff Show More