mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bac2fc447 | ||
|
|
e8a407cd49 | ||
|
|
bee1866a70 | ||
|
|
ddfc889a8c | ||
|
|
1ba4ebe5ff | ||
|
|
607d327e9c | ||
|
|
1bdca60648 | ||
|
|
ea4af2ebb3 | ||
|
|
b2bbaf2085 |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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)
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
autoChargeTimeout = 1 * time.Second
|
autoChargeTimeout = 10 * time.Second
|
||||||
autoChargeCooldown = 0 * time.Minute
|
autoChargeCooldown = 0 * time.Minute
|
||||||
sourceHomeBase = 1 << 1
|
sourceHomeBase = 1 << 1
|
||||||
)
|
)
|
||||||
|
|||||||
+163
-18
@@ -2,27 +2,34 @@
|
|||||||
|
|
||||||
## Resume here
|
## Resume here
|
||||||
|
|
||||||
Latest change: format 3 command-event policy replaces format 2 continuous wheel
|
Latest repair: full learning-path audit. Fixed missing command labels between
|
||||||
speeds. All supported controls, including drive and recorded zero-speed stops,
|
video samples with a shared causal 100ms grid; added real-data score/baseline/
|
||||||
use scored command templates and the same threshold/checked command pipe. The
|
training-probe diagnostics and normalized saved control profiles for retained
|
||||||
network produces scores and numeric values; it never generates JSON text.
|
recordings. Continues policy 5 / lineage v6; this audit does not reset progress.
|
||||||
|
See the final audit results section for measured synthetic evidence and limits.
|
||||||
|
|
||||||
Implemented: remove wheel-speed telemetry and previous drive-command history
|
Latest scope (authorized IPI): only drive, camera tilt, headlight and the four
|
||||||
from inputs (raw recordings unchanged); preserve exact numeric zeros; reuse
|
operating-mode/dock commands (128/131/132/143). Exclude horn, song, laser and all
|
||||||
recordings with a fresh resume-v4.pt / schedule-v4.json lineage. Startup deletes recognized older-format snapshots/checkpoints and old training
|
brush/vacuum motor commands from both learning and execution, including raw OI
|
||||||
lineages; recordings and current-format models are preserved. One ongoing learner, gated
|
bypasses. Raw recordings stay intact. Policy format 5, training lineage v6;
|
||||||
Latest/Previous, and admin-created permanent snapshots remain.
|
startup removes earlier models and lineages through v5. Earlier scope below is
|
||||||
|
historical. No configuration change required.
|
||||||
|
|
||||||
Main card shows decoded command parameters, score, applied threshold marker, and
|
Latest change (2026-09-29 IPI): format 4 seeds known user-control command shapes
|
||||||
submission results, including below-threshold proposals. Diagnostics reportVersion
|
before training. Generic peripherals are excluded; sensorStream is ignored without
|
||||||
3 includes command predictions/submissions and event evaluation. Manual inputs
|
invalidating footage. Drive includes zero-speed drive, with no separate stop type.
|
||||||
still never stop or pause autonomy. Disabled remains dormant/off by default.
|
Drive, camera, brushes/vacuum, headlight, laser, horn, songs and operating modes
|
||||||
|
remain supported. Raw recordings and the existing checked command pipe stay intact.
|
||||||
|
|
||||||
Focused Node syntax, Python production worker entry points, and card ESLint
|
Held-out data cannot expand the schema. Unknown control variants exclude nearby
|
||||||
passed. New policy: 803,440 parameters, 30 sensor fields, stateSize 700. No live
|
windows only, with command/count/reason diagnostics and a coverage gate that blocks
|
||||||
recordings/camera/rover pipeline or authenticated browser were available locally.
|
promotion. Supported portions still receive evaluation. Current training lineage
|
||||||
Actual training, stop behavior, concurrent performance and visual appearance
|
is resume-v5.pt / schedule-v5.json. Startup removes obsolete format 1-3 models and
|
||||||
remain unverified. No tests were added and no processes were left running.
|
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
|
This document preserves the conversation's decisions across compactions and
|
||||||
usage cutoffs. Keep the checklist and handoff section current during authorized
|
usage cutoffs. Keep the checklist and handoff section current during authorized
|
||||||
@@ -930,3 +937,141 @@ Cleanup runs even when training is disabled, but not when the whole service is o
|
|||||||
Production deletion occurs on the next enabled-service startup after deployment.
|
Production deletion occurs on the next enabled-service startup after deployment.
|
||||||
Verification: focused Node syntax and diff whitespace checks passed. No production
|
Verification: focused Node syntax and diff whitespace checks passed. No production
|
||||||
storage was accessed; actual deletion awaits enabled-service startup on the server.
|
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.
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function normalizeOutboundCommandPayload(payload = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueCommand(roverId, payload) {
|
function issueCommand(roverId, payload, options = {}) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
if (!record || !record.ws) {
|
if (!record || !record.ws) {
|
||||||
throw new Error('Rover offline');
|
throw new Error('Rover offline');
|
||||||
@@ -93,6 +93,11 @@ function issueCommand(roverId, payload) {
|
|||||||
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||||
throw new Error('Laser disabled while room lights are locked on');
|
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 id = uuidv4();
|
||||||
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
||||||
const message = { ...normalizedPayload, id };
|
const message = { ...normalizedPayload, id };
|
||||||
@@ -124,7 +129,14 @@ overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
|
|||||||
*/
|
*/
|
||||||
return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,6 +327,22 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
|||||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||||
throw new Error('Not your turn or no control');
|
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;
|
const driveDirect = payload?.driveDirect;
|
||||||
if (type === 'drive' && driveDirect) {
|
if (type === 'drive' && driveDirect) {
|
||||||
if (!isAdminSocket) {
|
if (!isAdminSocket) {
|
||||||
@@ -361,7 +389,14 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
|||||||
bypassed: isAdminSocket,
|
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', {
|
commandEvents.emit('observation', {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
roverId: String(roverId),
|
roverId: String(roverId),
|
||||||
|
|||||||
@@ -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
|
// User and automated commands share validation; only trusted automation bypasses user permissions.
|
||||||
// idle entry point can bypass per-item user locks.
|
|
||||||
const { buildEntity, buildCommand } = require('./entityHelpers');
|
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||||
|
|
||||||
function assertAccess(actor = {}) {
|
function assertAccess(actor = {}) {
|
||||||
@@ -9,13 +8,14 @@ function assertAccess(actor = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createActions({ getConfig, ha, locks }) {
|
function createActions({ getConfig, ha, locks }) {
|
||||||
async function execute(id, value, actor, idle = false) {
|
async function execute(id, value, actor, automated = false) {
|
||||||
if (!idle) assertAccess(actor);
|
if (!automated) assertAccess(actor);
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config.enabled) throw new Error('Activity Controls are disabled');
|
if (!config.enabled) throw new Error('Activity Controls are disabled');
|
||||||
const item = config.items.find((entry) => entry.id === id);
|
const item = config.items.find((entry) => entry.id === id);
|
||||||
if (!item) throw new Error('Unknown activity item');
|
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');
|
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
|
||||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
||||||
if (!entity.available) throw new Error('This item is unavailable');
|
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);
|
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runIdleActions() {
|
async function runAutomation(trigger, shouldRun = () => true) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
|
if (!config.enabled) return { action: trigger, skipped: true };
|
||||||
const results = [];
|
const results = [];
|
||||||
// Catch per item so one offline integration cannot prevent the remaining
|
// 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) {
|
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 {
|
try {
|
||||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
||||||
if (entity.type === 'readOnly') continue;
|
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
|
// The admin form omits an empty optional string. Treat that as empty
|
||||||
// text so clearing a message works; other types still reject it through
|
// text so clearing a message works; other types still reject it through
|
||||||
// their normal value validation rather than silently receiving zero.
|
// 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 });
|
results.push({ id: item.id, ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ id: item.id, ok: false, error: error.message });
|
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 };
|
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('unknown')).available, true);
|
||||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
||||||
assert.equal(buildEntity({ id: 'button.bell' }, null).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 () => {
|
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 () => {
|
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
||||||
const h = harness([
|
const h = harness([
|
||||||
{ id: 'switch.keep' },
|
{ id: 'switch.keep' },
|
||||||
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
|
{ id: 'number.bad', automations: { idle: { action: 'set', value: '200' } } },
|
||||||
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
|
{ id: 'switch.fan', automations: { idle: { action: 'set', value: 'off' } } },
|
||||||
// An empty optional text box is omitted by the schema-driven admin form.
|
// An empty optional text box is omitted by the schema-driven admin form.
|
||||||
{ id: 'input_text.message', idleAction: 'set' },
|
{ id: 'input_text.message', automations: { idle: { action: 'set' } } },
|
||||||
{ id: 'input_button.stop', idleAction: 'press' },
|
{ id: 'input_button.stop', automations: { idle: { action: 'press' } } },
|
||||||
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
|
{ id: 'sensor.humidity', automations: { idle: { action: 'set', value: '0' } } },
|
||||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
|
{ 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'),
|
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
||||||
});
|
});
|
||||||
h.locked.add('switch.fan');
|
h.locked.add('switch.fan');
|
||||||
const result = await h.actions.runIdleActions();
|
const result = await h.actions.runIdleActions();
|
||||||
assert.equal(result.results[0].ok, false);
|
assert.equal(result.results[0].ok, false);
|
||||||
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
|
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' }]);
|
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 () => {
|
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.
|
// never acquire unrelated devices simply because they share a connection.
|
||||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
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 = {
|
module.exports = {
|
||||||
key: 'homeAssistantActivities',
|
key: 'homeAssistantActivities',
|
||||||
feature: true,
|
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'] }),
|
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.
|
// 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}$' }),
|
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.' }),
|
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.' }),
|
||||||
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.' }),
|
automations: strictObject({
|
||||||
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 }),
|
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'] }),
|
}, { 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'] }),
|
}, { 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) {
|
function buildEntity(item, raw, locked = false) {
|
||||||
const attributes = raw?.attributes || {};
|
const attributes = raw?.attributes || {};
|
||||||
const domain = item.id.split('.')[0];
|
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
|
// A never-pressed button legitimately reports unknown. Its state is a last
|
||||||
// press timestamp, not availability or a boolean toggle state.
|
// press timestamp, not availability or a boolean toggle state.
|
||||||
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
|
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
|
||||||
@@ -17,7 +18,7 @@ function buildEntity(item, raw, locked = false) {
|
|||||||
return {
|
return {
|
||||||
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
|
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.
|
// 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 || '',
|
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
|
||||||
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
|
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
|
||||||
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
|
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 { createLocks, resolveItem } = require('./locks');
|
||||||
const { createActions } = require('./actions');
|
const { createActions } = require('./actions');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
const { getMode } = require('../modeManager');
|
const { getMode, modeEvents } = require('../modeManager');
|
||||||
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
|
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
|
||||||
|
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
@@ -24,6 +24,35 @@ const actions = createActions({ getConfig: () => config, ha: {
|
|||||||
callHomeAssistantService: ha.callHomeAssistantService,
|
callHomeAssistantService: ha.callHomeAssistantService,
|
||||||
}, locks });
|
}, 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() {
|
function getState() {
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
@@ -53,14 +82,17 @@ function setLocked(query, locked) {
|
|||||||
ha.homeAssistantEvents.on('snapshot', () => {
|
ha.homeAssistantEvents.on('snapshot', () => {
|
||||||
snapshotReady = true;
|
snapshotReady = true;
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
refreshAutomation();
|
||||||
});
|
});
|
||||||
ha.homeAssistantEvents.on('status', () => {
|
ha.homeAssistantEvents.on('status', () => {
|
||||||
snapshotReady = false;
|
snapshotReady = false;
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
refreshAutomation();
|
||||||
});
|
});
|
||||||
registerConfigurationHandler('homeAssistantActivities', (next) => {
|
registerConfigurationHandler('homeAssistantActivities', (next) => {
|
||||||
config = next;
|
config = next;
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
refreshAutomation();
|
||||||
});
|
});
|
||||||
// Resolve access on the server for every command; clients only supply item/value.
|
// Resolve access on the server for every command; clients only supply item/value.
|
||||||
io.on('connection', (socket) => {
|
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),
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
const logger = require('../../globals/logger').child('idleService');
|
const logger = require('../../globals/logger').child('idleService');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
|
const { isDeterred, verificationEvents } = require('../verificationService');
|
||||||
const { IDLE_TIMEOUT_MS } = require('./constants');
|
const { IDLE_TIMEOUT_MS } = require('./constants');
|
||||||
const { runtime } = require('./state');
|
const { runtime } = require('./state');
|
||||||
const { runIdleActions } = require('./actions');
|
const { runIdleActions } = require('./actions');
|
||||||
|
const activityControls = require('../homeAssistantActivitiesService');
|
||||||
|
|
||||||
function getActivitySnapshot() {
|
function getActivitySnapshot() {
|
||||||
|
const mode = getMode();
|
||||||
let onlineUsers = 0;
|
let onlineUsers = 0;
|
||||||
let onlineAdmins = 0;
|
let onlineAdmins = 0;
|
||||||
let onlineSpectators = 0;
|
let onlineSpectators = 0;
|
||||||
@@ -28,12 +32,16 @@ function getActivitySnapshot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
Lockdown admins are counted with regular admins because both represent a
|
if (
|
||||||
person with operator-level access who may be supervising the room without
|
isDeterred(socket)
|
||||||
actively driving a rover. Plain users also count even before they request
|
|| (mode === MODES.ADMIN && role !== 'admin' && role !== 'lockdown')
|
||||||
control, which is the behavior this service now needs.
|
|| (mode === MODES.LOCKDOWN && role !== 'lockdown')
|
||||||
*/
|
) {
|
||||||
|
onlineIgnored += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (role === 'admin' || role === 'lockdown') {
|
if (role === 'admin' || role === 'lockdown') {
|
||||||
onlineAdmins += 1;
|
onlineAdmins += 1;
|
||||||
return;
|
return;
|
||||||
@@ -89,7 +97,7 @@ function scheduleIdleTimer() {
|
|||||||
runtime.deadlineAt = null;
|
runtime.deadlineAt = null;
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
logger.info('Idle automation skipped; user or admin online', activity);
|
logger.info('Idle automation skipped; eligible operator online', activity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastTriggeredAt = Date.now();
|
runtime.lastTriggeredAt = Date.now();
|
||||||
@@ -113,16 +121,12 @@ function scheduleIdleTimer() {
|
|||||||
function refreshIdleState() {
|
function refreshIdleState() {
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
logger.info('Idle state refresh', activity);
|
logger.info('Idle state refresh', activity);
|
||||||
|
activityControls.setOperatorsOnline(activity.totalActive > 0);
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
if (runtime.idleActionsCompleted) {
|
if (runtime.idleActionsCompleted) {
|
||||||
logger.info('Idle action one-shot reset; operator is online again', activity);
|
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;
|
runtime.idleActionsCompleted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -130,17 +134,13 @@ function refreshIdleState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
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();
|
refreshIdleState();
|
||||||
socket.on('disconnect', refreshIdleState);
|
socket.on('disconnect', refreshIdleState);
|
||||||
});
|
});
|
||||||
|
|
||||||
roleEvents.on('change', refreshIdleState);
|
roleEvents.on('change', refreshIdleState);
|
||||||
|
modeEvents.on('change', refreshIdleState);
|
||||||
|
verificationEvents.on('change', refreshIdleState);
|
||||||
|
|
||||||
refreshIdleState();
|
refreshIdleState();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Room-Camera Configuration
|
// Room-Camera Configuration
|
||||||
// Purpose: Defines the optional named snapshot and stream camera catalog.
|
// Purpose: Defines the optional named snapshot and stream camera catalog.
|
||||||
// Scope: Contains configuration metadata only and never contacts a camera.
|
// 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 = {
|
module.exports = {
|
||||||
key: 'roomCameras',
|
key: 'roomCameras',
|
||||||
@@ -10,6 +10,7 @@ module.exports = {
|
|||||||
// initial configuration while the feature switch prevents network requests.
|
// initial configuration while the feature switch prevents network requests.
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
streamIntervalMs: 2000,
|
||||||
cameras: [
|
cameras: [
|
||||||
{
|
{
|
||||||
id: 'lobby',
|
id: 'lobby',
|
||||||
@@ -29,6 +30,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
schema: strictObject({
|
schema: strictObject({
|
||||||
enabled: boolean({ description: 'Immediately publishes the configured room-camera catalog and enables camera snapshots and streams.' }),
|
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: {
|
cameras: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Room cameras available to the web UI and replay system.',
|
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');
|
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
||||||
|
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
|
let streamIntervalMs = 2000;
|
||||||
|
|
||||||
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
||||||
function applyRoomCameraConfig(roomCameraConfig = {}) {
|
function applyRoomCameraConfig(roomCameraConfig = {}) {
|
||||||
enabled = Boolean(roomCameraConfig.enabled);
|
enabled = Boolean(roomCameraConfig.enabled);
|
||||||
|
streamIntervalMs = roomCameraConfig.streamIntervalMs ?? 2000;
|
||||||
// Loading an empty catalog on disable causes the snapshot engine's existing
|
// Loading an empty catalog on disable causes the snapshot engine's existing
|
||||||
// update listener to close every stream and timer without unregistering the
|
// update listener to close every stream and timer without unregistering the
|
||||||
// stable browser gateway.
|
// stable browser gateway.
|
||||||
@@ -19,6 +21,7 @@ function applyRoomCameraConfig(roomCameraConfig = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerRoomCameraSocketGateway({
|
registerRoomCameraSocketGateway({
|
||||||
|
getStreamIntervalMs: () => streamIntervalMs,
|
||||||
getRoomCamera,
|
getRoomCamera,
|
||||||
getRoomCameras,
|
getRoomCameras,
|
||||||
getRoomCameraState: snapshotEngine.getRoomCameraState,
|
getRoomCameraState: snapshotEngine.getRoomCameraState,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
|||||||
|
|
||||||
const SUBSCRIBE_LIMIT = 50;
|
const SUBSCRIBE_LIMIT = 50;
|
||||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||||
const STREAM_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
@@ -20,7 +19,7 @@ function passesMode(socket) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents }) {
|
function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents, getStreamIntervalMs }) {
|
||||||
const cameraSubscribers = new Map();
|
const cameraSubscribers = new Map();
|
||||||
const socketSubscriptions = new Map();
|
const socketSubscriptions = new Map();
|
||||||
const subscribeBuckets = new Map();
|
const subscribeBuckets = new Map();
|
||||||
@@ -82,7 +81,7 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
|
|||||||
}
|
}
|
||||||
const lastSent = lastMap.get(id) || 0;
|
const lastSent = lastMap.get(id) || 0;
|
||||||
const now = ts || Date.now();
|
const now = ts || Date.now();
|
||||||
if (now - lastSent < STREAM_INTERVAL_MS) return;
|
if (now - lastSent < getStreamIntervalMs()) return;
|
||||||
lastMap.set(id, now);
|
lastMap.set(id, now);
|
||||||
sendFrame(socket, id, { ts }, buffer);
|
sendFrame(socket, id, { ts }, buffer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
// Match actuator configuration, not a rover's display name or changing telemetry.
|
// Match actuator configuration, not a rover's display name or changing telemetry.
|
||||||
const { isDeepStrictEqual } = require('node:util');
|
const { isDeepStrictEqual } = require('node:util');
|
||||||
const POLICY_VERSION = 3;
|
const POLICY_VERSION = 5;
|
||||||
|
|
||||||
function controlProfile(meta = {}) {
|
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]));
|
.map((key) => [key, meta?.[key] ?? null]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function compatible(model, record) {
|
function compatible(model, record) {
|
||||||
if (model?.specification?.version !== POLICY_VERSION || !record) return false;
|
if (model?.specification?.version !== POLICY_VERSION || !record) return false;
|
||||||
const profile = controlProfile(record.meta);
|
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 = { POLICY_VERSION, controlProfile, compatible };
|
module.exports = { POLICY_VERSION, controlProfile, compatible };
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const path = require('node:path');
|
|||||||
const { createHash } = require('node:crypto');
|
const { createHash } = require('node:crypto');
|
||||||
const applicationVersion = require('../../../package.json').version;
|
const applicationVersion = require('../../../package.json').version;
|
||||||
const hash = createHash('sha256');
|
const hash = createHash('sha256');
|
||||||
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py',
|
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']) {
|
'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)));
|
hash.update(fs.readFileSync(path.join(__dirname, file)));
|
||||||
}
|
}
|
||||||
@@ -41,11 +41,12 @@ function report(state, controller) {
|
|||||||
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
|
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
|
||||||
status: item.video, droppedEvents: item.droppedEvents, error: redact(item.error), failureDetails: item.failureDetails || null, stderr: redact(item.stderr), 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,
|
training: { status: training.status, steps: training.steps, examples: training.examples,
|
||||||
selection: training.selection || null,
|
selection: training.selection || null, evaluatedSteps: training.evaluatedSteps ?? null,
|
||||||
loss: training.loss, losses: training.losses || null, publication: training.publication || 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,
|
evaluation: training.evaluation || null, dataset: metrics.dataset || training.dataset || null,
|
||||||
resources: { decodeSeconds: metrics.decodeSeconds ?? null, trainingSeconds: metrics.trainingSeconds ?? 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 },
|
peakRssMiB: metrics.peakRssMiB ?? null },
|
||||||
history: training.history || [], rejectionReasons: (training.rejectionReasons || []).map(redact), rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
|
history: training.history || [], rejectionReasons: (training.rejectionReasons || []).map(redact), rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
|
||||||
error: redact(training.error), errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
|
error: redact(training.error), errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const { recordCommandRequest } = require('../commandService/recording');
|
|||||||
const { acquireModel } = require('./models');
|
const { acquireModel } = require('./models');
|
||||||
const { compatible } = require('./capabilities');
|
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));
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value) || 0));
|
||||||
|
|
||||||
function canControl(socket, roverId) {
|
function canControl(socket, roverId) {
|
||||||
@@ -34,14 +35,12 @@ function createDriving({ root, config, onChange, activity }) {
|
|||||||
if (session.status === 'stopping') return;
|
if (session.status === 'stopping') return;
|
||||||
if (!canControl(session.socket, session.roverId)) { stop(session.roverId, 'Control permission lost'); 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 (!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);
|
const record = roverManager.rovers.get(session.roverId);
|
||||||
if (command.type === 'drive') {
|
if (command.type === 'drive') {
|
||||||
const limit = Math.min(500, Number(record.meta?.maxWheelSpeed) || 500);
|
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)) };
|
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') {
|
if (command.type === 'servo') {
|
||||||
const servo = record.meta?.cameraServo;
|
const servo = record.meta?.cameraServo;
|
||||||
if (!servo?.enabled) { session.submissions.push({ command, result: 'Servo unavailable' }); return; }
|
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.
|
// A stop must still reach the rover after turn permission is revoked.
|
||||||
for (const command of [
|
for (const command of [
|
||||||
{ type: 'drive', driveDirect: { left: 0, right: 0 } },
|
{ 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. */ } }
|
]) { try { issueCommand(roverId, command); } catch { /* Rover may already be offline. */ } }
|
||||||
session.worker.kill('SIGTERM');
|
session.worker.kill('SIGTERM');
|
||||||
const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000);
|
const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000);
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ function removeObsoleteModels(root) {
|
|||||||
else { await fs.rm(target, { recursive: true, force: true }); retired.delete(target); }
|
else { await fs.rm(target, { recursive: true, force: true }); retired.delete(target); }
|
||||||
}
|
}
|
||||||
// These filenames belong to superseded optimizer/scheduler lineages, not
|
// These filenames belong to superseded optimizer/scheduler lineages, not
|
||||||
// policy versions. The current command policy uses lineage v4.
|
// policy versions. The current command policy uses lineage v6.
|
||||||
for (const suffix of ['', '-v1', '-v2', '-v3']) {
|
for (const suffix of ['', '-v1', '-v2', '-v3', '-v4', '-v5']) {
|
||||||
for (const filename of [`resume${suffix}.pt`, `resume${suffix}.tmp`,
|
for (const filename of [`resume${suffix}.pt`, `resume${suffix}.tmp`,
|
||||||
`schedule${suffix}.json`, `schedule${suffix}.json.tmp`]) {
|
`schedule${suffix}.json`, `schedule${suffix}.json.tmp`]) {
|
||||||
await fs.rm(path.join(root, 'training', filename), { force: true });
|
await fs.rm(path.join(root, 'training', filename), { force: true });
|
||||||
|
|||||||
@@ -33,10 +33,17 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v4.json'), 'utf8'));
|
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v6.json'), 'utf8'));
|
||||||
ledger = saved.sessions;
|
ledger = saved.sessions;
|
||||||
state = { ...saved.progress, status: 'waiting' };
|
state = { ...saved.progress, status: 'waiting' };
|
||||||
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
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) { if (error.code !== 'ENOENT') throw error; }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
startupError = true;
|
startupError = true;
|
||||||
@@ -46,8 +53,8 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveLedger() {
|
async function saveLedger() {
|
||||||
const filename = path.join(trainingRoot, 'schedule-v4.json');
|
const filename = path.join(trainingRoot, 'schedule-v6.json');
|
||||||
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
|
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ samplingVersion: 2, sessions: ledger, progress: state }));
|
||||||
await fs.rename(`${filename}.tmp`, filename);
|
await fs.rename(`${filename}.tmp`, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +166,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
|
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
|
||||||
completedAt: Date.now(), elapsedSeconds: result.elapsedSeconds ?? null, error: null,
|
completedAt: Date.now(), elapsedSeconds: result.elapsedSeconds ?? null, error: null,
|
||||||
examples: result.examples ?? state.examples, loss: result.loss ?? 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,
|
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' };
|
reason: result.reason ?? 'Waiting for the next scheduled training job' };
|
||||||
await saveLedger();
|
await saveLedger();
|
||||||
@@ -169,7 +177,9 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
logger.error('Training result persistence failed', { error: error.message, failure: errorDetails(error, 'training result persistence') });
|
logger.error('Training result persistence failed', { error: error.message, failure: errorDetails(error, 'training result persistence') });
|
||||||
} finally {
|
} finally {
|
||||||
history.push({ at: Date.now(), status: state.status, steps: state.steps, loss: state.loss,
|
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 });
|
elapsedSeconds: state.elapsedSeconds || null });
|
||||||
if (history.length > 20) history.shift();
|
if (history.length > 20) history.shift();
|
||||||
leases.clear();
|
leases.clear();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import av
|
import av
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from observations import decision_frames, history_frames
|
||||||
|
|
||||||
from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector
|
from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ def load_events(directory):
|
|||||||
return metadata, sorted(events, key=lambda e: e['ts'])
|
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)
|
metadata, events = load_events(directory)
|
||||||
requests = {}
|
requests = {}
|
||||||
accepted = set()
|
accepted = set()
|
||||||
@@ -72,14 +73,28 @@ def prepare(directory, schema, stats):
|
|||||||
if not sensors:
|
if not sensors:
|
||||||
raise ValueError('No sensor observations')
|
raise ValueError('No sensor observations')
|
||||||
candidate = copy.deepcopy(schema)
|
candidate = copy.deepcopy(schema)
|
||||||
history, targets = [], []
|
history, targets, unsupported_times = [], [], []
|
||||||
|
unsupported = {}
|
||||||
|
dispatch_delays = []
|
||||||
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
|
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
|
||||||
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][len(SENSORS):] 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:
|
for data in dispatches:
|
||||||
command = data.get('command') or {}
|
command = data.get('command') or {}
|
||||||
if command.get('type') in ('reset', 'reboot', 'update', 'sensorStream'):
|
if command.get('type') in ('reset', 'reboot', 'update'):
|
||||||
boundaries.append(data['ts'])
|
boundaries.append(data['ts'])
|
||||||
encoded = encode_command(command, candidate, extend=True)
|
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:
|
if encoded is None:
|
||||||
continue
|
continue
|
||||||
stamp = data['ts']
|
stamp = data['ts']
|
||||||
@@ -90,35 +105,43 @@ def prepare(directory, schema, stats):
|
|||||||
target_time = request['ts']
|
target_time = request['ts']
|
||||||
if not 0 <= stamp - target_time <= 1000:
|
if not 0 <= stamp - target_time <= 1000:
|
||||||
raise ValueError('Invalid request/dispatch timing')
|
raise ValueError('Invalid request/dispatch timing')
|
||||||
|
dispatch_delays.append(stamp - target_time)
|
||||||
targets.append((target_time, *encoded))
|
targets.append((target_time, *encoded))
|
||||||
else:
|
else:
|
||||||
# Server safety overrides belong in history, never demonstration
|
# Server safety overrides belong in history, never demonstration
|
||||||
# targets. Exclude windows around them rather than teaching inaction.
|
# targets. Exclude windows around them rather than teaching inaction.
|
||||||
boundaries.append(stamp)
|
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')
|
raise ValueError('No accepted client operating commands')
|
||||||
schema[:] = candidate
|
schema[:] = candidate
|
||||||
return metadata, sensors, 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)
|
directory = Path(directory)
|
||||||
stats = stats if stats is not None else {}
|
stats = stats if stats is not None else {}
|
||||||
metadata, sensors, history, targets, boundaries = prepare(directory, schema, stats)
|
metadata, sensors, history, targets, boundaries, unsupported_times = prepare(directory, schema, stats, extend)
|
||||||
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
|
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
|
||||||
'staleSensorWindows': 0, 'boundaryWindows': 0,
|
'staleSensorWindows': 0, 'boundaryWindows': 0, 'maxSensorAgeMs': 0, 'maxFrameAgeMs': 0,
|
||||||
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
|
'unsupportedWindows': 0, 'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
|
||||||
sensor_times = [row[0] for row in sensors]
|
sensor_times = [row[0] for row in sensors]
|
||||||
target_times = [row[0] for row in targets]
|
target_times = [row[0] for row in targets]
|
||||||
available = np.zeros(SLOTS, dtype=np.float32)
|
available = np.zeros(SLOTS, dtype=np.float32)
|
||||||
for _, slot, _ in history:
|
available[:len(schema)] = 1
|
||||||
available[slot] = 1
|
|
||||||
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||||
command_times = np.full(SLOTS, -np.inf)
|
command_times = np.full(SLOTS, -np.inf)
|
||||||
cursor = 0
|
cursor = 0
|
||||||
recent = collections.deque(maxlen=32)
|
recent = collections.deque(maxlen=32)
|
||||||
last_sample, last_pts = -math.inf, -math.inf
|
last_pts = -math.inf
|
||||||
def frames():
|
def frames():
|
||||||
|
nonlocal last_pts
|
||||||
files = metadata['video'].get('files', ['video.mkv'])
|
files = metadata['video'].get('files', ['video.mkv'])
|
||||||
for name in files:
|
for name in files:
|
||||||
if Path(name).name != name:
|
if Path(name).name != name:
|
||||||
@@ -126,27 +149,24 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
with av.open(str(directory / name)) as container:
|
with av.open(str(directory / name)) as container:
|
||||||
stream = container.streams.video[0]
|
stream = container.streams.video[0]
|
||||||
stream.codec_context.thread_count = 1
|
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():
|
if cancelled():
|
||||||
return
|
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']):
|
if stamp < metadata.get('trainingStartedAt', metadata['startedAt']):
|
||||||
continue
|
continue
|
||||||
if stamp - last_sample < 99:
|
|
||||||
continue
|
|
||||||
last_sample = stamp
|
|
||||||
while cursor < len(history) and history[cursor][0] < stamp:
|
while cursor < len(history) and history[cursor][0] < stamp:
|
||||||
when, slot, values = history[cursor]
|
when, slot, values = history[cursor]
|
||||||
# Do not let drive predictions simply copy earlier human/model drive commands.
|
# Do not let drive predictions simply copy earlier human/model drive commands.
|
||||||
@@ -154,24 +174,24 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
command_values[slot] = values
|
command_values[slot] = values
|
||||||
command_times[slot] = when
|
command_times[slot] = when
|
||||||
cursor += 1
|
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:
|
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
|
||||||
stats['staleSensorWindows'] += 1
|
stats['staleSensorWindows'] += 1
|
||||||
recent.clear()
|
recent.clear()
|
||||||
continue
|
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)
|
seen = np.isfinite(command_times).astype(np.float32)
|
||||||
ages = np.minimum((stamp - command_times) / 10000, 1).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))
|
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()
|
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
||||||
recent.append((stamp, pixels, state))
|
recent.append((stamp, pixels, state))
|
||||||
chosen = []
|
chosen = history_frames(recent, stamp)
|
||||||
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])
|
|
||||||
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
|
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
|
||||||
continue
|
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]):
|
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
|
||||||
stats['boundaryWindows'] += 1
|
stats['boundaryWindows'] += 1
|
||||||
continue
|
continue
|
||||||
@@ -184,7 +204,7 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
for _, slot, vector in targets[start:end]:
|
for _, slot, vector in targets[start:end]:
|
||||||
template = schema[slot]['template']
|
template = schema[slot]['template']
|
||||||
family = command_family(template)
|
family = command_family(template)
|
||||||
if ((actions[slot] and (template['type'] in ('headlight', 'laser', 'horn', 'song', 'raw') or not np.array_equal(values[slot], vector)))
|
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)):
|
or (not actions[slot] and family in families)):
|
||||||
ambiguous = True
|
ambiguous = True
|
||||||
break
|
break
|
||||||
@@ -193,6 +213,10 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
if ambiguous:
|
if ambiguous:
|
||||||
stats['ambiguousWindows'] += 1
|
stats['ambiguousWindows'] += 1
|
||||||
continue
|
continue
|
||||||
|
represented.update(range(start, end))
|
||||||
|
stats['representedCommandEvents'] = len(represented)
|
||||||
|
for slot in np.flatnonzero(actions):
|
||||||
|
stats['commandEventCounts'][slot] += 1
|
||||||
stats['usableWindows'] += 1
|
stats['usableWindows'] += 1
|
||||||
stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1
|
stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1
|
||||||
stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema)))
|
stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema)))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import time
|
|||||||
import av
|
import av
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
from observations import decision_frames, history_frames
|
||||||
|
|
||||||
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector
|
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector
|
||||||
|
|
||||||
@@ -54,23 +55,16 @@ def main():
|
|||||||
sensors, sensor_time = {}, 0
|
sensors, sensor_time = {}, 0
|
||||||
history = collections.deque(maxlen=32)
|
history = collections.deque(maxlen=32)
|
||||||
latched = set()
|
latched = set()
|
||||||
sampled = 0
|
|
||||||
with av.open(args.url, options={'rtsp_transport': 'tcp', 'timeout': '3000000',
|
with av.open(args.url, options={'rtsp_transport': 'tcp', 'timeout': '3000000',
|
||||||
'use_wallclock_as_timestamps': '1'}) as video:
|
'use_wallclock_as_timestamps': '1'}) as video:
|
||||||
stream = video.streams.video[0]
|
stream = video.streams.video[0]
|
||||||
stream.codec_context.thread_count = 1
|
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():
|
if closed.is_set():
|
||||||
return
|
return
|
||||||
if frame.pts is None:
|
|
||||||
continue
|
|
||||||
stamp = float(frame.pts * frame.time_base) * 1000
|
|
||||||
now = time.time() * 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')
|
raise ValueError('Video time is stale or not aligned to server time')
|
||||||
if stamp - sampled < 99:
|
|
||||||
continue
|
|
||||||
sampled = stamp
|
|
||||||
with mutex:
|
with mutex:
|
||||||
while observations and observations[0]['ts'] < stamp:
|
while observations and observations[0]['ts'] < stamp:
|
||||||
event = observations.popleft()
|
event = observations.popleft()
|
||||||
@@ -94,12 +88,7 @@ def main():
|
|||||||
np.isfinite(last_commands).astype(np.float32)))
|
np.isfinite(last_commands).astype(np.float32)))
|
||||||
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
||||||
history.append((stamp, pixels, state))
|
history.append((stamp, pixels, state))
|
||||||
selected = []
|
selected = history_frames(history, stamp)
|
||||||
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])
|
|
||||||
if len(selected) != 4:
|
if len(selected) != 4:
|
||||||
continue
|
continue
|
||||||
before = time.perf_counter()
|
before = time.perf_counter()
|
||||||
@@ -116,7 +105,7 @@ def main():
|
|||||||
proposals, commands = [], []
|
proposals, commands = [], []
|
||||||
for slot, descriptor in enumerate(schema):
|
for slot, descriptor in enumerate(schema):
|
||||||
command = decode_command(descriptor, predictions[slot])
|
command = decode_command(descriptor, predictions[slot])
|
||||||
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
discrete = command['type'] in ('headlight', 'raw')
|
||||||
reason = ('Below threshold' if slot not in active else
|
reason = ('Below threshold' if slot not in active else
|
||||||
'Competing action' if slot not in winners else
|
'Competing action' if slot not in winners else
|
||||||
'Held discrete action' if discrete and slot in latched else 'Proposed')
|
'Held discrete action' if discrete and slot in latched else 'Proposed')
|
||||||
@@ -125,7 +114,7 @@ def main():
|
|||||||
commands.append(command)
|
commands.append(command)
|
||||||
if slot in winners and discrete:
|
if slot in winners and discrete:
|
||||||
latched.add(slot)
|
latched.add(slot)
|
||||||
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,
|
'latencyMs': (time.perf_counter() - before) * 1000,
|
||||||
'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(),
|
'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(),
|
||||||
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
|
'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.
|
leaves are regressed; string/bool/list values remain exact template constants.
|
||||||
No command is sent by this module. Execution must use server authorization.
|
No command is sent by this module. Execution must use server authorization.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
|
import itertools
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -12,12 +14,13 @@ import numpy as np
|
|||||||
import torch
|
import torch
|
||||||
from torch import nn
|
from torch import nn
|
||||||
|
|
||||||
VERSION = 3
|
VERSION = 5
|
||||||
SLOTS = 64
|
SLOTS = 64
|
||||||
VALUES = 8
|
VALUES = 8
|
||||||
FRAMES = 4
|
FRAMES = 4
|
||||||
HEIGHT, WIDTH = 120, 160
|
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 = [
|
SENSORS = [
|
||||||
("bumpsAndWheelDrops.bumpLeft", 1), ("bumpsAndWheelDrops.bumpRight", 1),
|
("bumpsAndWheelDrops.bumpLeft", 1), ("bumpsAndWheelDrops.bumpRight", 1),
|
||||||
("bumpsAndWheelDrops.wheelDropLeft", 1), ("bumpsAndWheelDrops.wheelDropRight", 1),
|
("bumpsAndWheelDrops.wheelDropLeft", 1), ("bumpsAndWheelDrops.wheelDropRight", 1),
|
||||||
@@ -54,6 +57,10 @@ def sensor_vector(sensors):
|
|||||||
def describe_command(command):
|
def describe_command(command):
|
||||||
if command.get('type') not in OPERATING_TYPES:
|
if command.get('type') not in OPERATING_TYPES:
|
||||||
return None
|
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:
|
if len(json.dumps(command)) > 4096:
|
||||||
raise ValueError('Operating command exceeds 4096-byte schema limit')
|
raise ValueError('Operating command exceeds 4096-byte schema limit')
|
||||||
values, fields = [], []
|
values, fields = [], []
|
||||||
@@ -61,20 +68,17 @@ def describe_command(command):
|
|||||||
def visit(value, path=()):
|
def visit(value, path=()):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
|
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
|
||||||
# Exact zeros preserve demonstrated releases/stops. Identity fields must
|
# Exact actuator zeros preserve demonstrated releases.
|
||||||
# also stay exact: regression must never invent a peripheral address.
|
if value == 0:
|
||||||
if value == 0 or (path and path[-1] in ('id', 'control')):
|
|
||||||
return copy.deepcopy(value)
|
return copy.deepcopy(value)
|
||||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
if not math.isfinite(value) or len(values) >= VALUES:
|
if not math.isfinite(value) or len(values) >= VALUES:
|
||||||
raise ValueError('Nonfinite command or too many numeric control fields')
|
raise ValueError('Nonfinite command or too many numeric control fields')
|
||||||
scale = {'drive': 500, 'motors': 255, 'servo': 180, 'peripheral': 255,
|
scale = {'drive': 500, 'servo': 180}.get(command['type'], 1)
|
||||||
'horn': 1000, 'song': 127}.get(command['type'], 1)
|
fields.append({'path': list(path), 'scale': scale, 'integer': command['type'] in ('drive', 'servo') or isinstance(value, int)})
|
||||||
fields.append({'path': list(path), 'scale': scale, 'integer': isinstance(value, int)})
|
|
||||||
values.append(normalize(value, scale))
|
values.append(normalize(value, scale))
|
||||||
return {'$number': len(values) - 1}
|
return {'$number': len(values) - 1}
|
||||||
# Arrays (e.g. songs) are discrete exact demonstrations, not variable
|
# Mode bytes and named actions remain exact constants.
|
||||||
# length regression outputs. Never reinterpret raw OI bytes as numbers.
|
|
||||||
return copy.deepcopy(value)
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
template = visit(command)
|
template = visit(command)
|
||||||
@@ -92,7 +96,7 @@ def encode_command(command, schema, extend=False):
|
|||||||
if not extend:
|
if not extend:
|
||||||
raise ValueError('Command shape missing from model schema')
|
raise ValueError('Command shape missing from model schema')
|
||||||
if len(schema) >= SLOTS:
|
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)
|
slot = len(schema)
|
||||||
schema.append(descriptor)
|
schema.append(descriptor)
|
||||||
vector = np.zeros(VALUES, dtype=np.float32)
|
vector = np.zeros(VALUES, dtype=np.float32)
|
||||||
@@ -100,10 +104,25 @@ def encode_command(command, schema, extend=False):
|
|||||||
return slot, vector
|
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):
|
def command_family(command):
|
||||||
if command['type'] == 'peripheral':
|
|
||||||
peripheral = command.get('peripheral') or {}
|
|
||||||
return ('peripheral', str(peripheral.get('id')), str(peripheral.get('control')))
|
|
||||||
return (command['type'],)
|
return (command['type'],)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +181,7 @@ class Policy(nn.Module):
|
|||||||
|
|
||||||
|
|
||||||
def specification(schema):
|
def specification(schema):
|
||||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v3',
|
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v5',
|
||||||
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
||||||
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
||||||
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ def main():
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import av
|
import av
|
||||||
import torch
|
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 dataset import windows
|
||||||
from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots
|
from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots, initial_schema
|
||||||
|
|
||||||
if args.check_dependencies:
|
if args.check_dependencies:
|
||||||
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
|
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
|
return
|
||||||
if not args.job:
|
if not args.job:
|
||||||
parser.error('--job is required unless --check-dependencies is used')
|
parser.error('--job is required unless --check-dependencies is used')
|
||||||
@@ -72,12 +72,13 @@ def main():
|
|||||||
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
||||||
# The command-event policy has different inputs, targets and weights. Keep its
|
# The command-event policy has different inputs, targets and weights. Keep its
|
||||||
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
||||||
resume_path = root / 'training' / 'resume-v4.pt'
|
resume_path = root / 'training' / 'resume-v6.pt'
|
||||||
schema, steps, examples, last_published = [], 0, 0, 0
|
schema, steps, examples, last_published = initial_schema(), 0, 0, 0
|
||||||
trained_rovers = set()
|
trained_rovers = set()
|
||||||
control_profiles = []
|
control_profiles = []
|
||||||
distinct_windows = {}
|
distinct_windows = {}
|
||||||
published_windows = 0
|
published_windows = 0
|
||||||
|
last_probe_steps, learning_probe = 0, None
|
||||||
if resume_path.exists():
|
if resume_path.exists():
|
||||||
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
|
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
|
||||||
if saved['version'] != VERSION:
|
if saved['version'] != VERSION:
|
||||||
@@ -91,6 +92,8 @@ def main():
|
|||||||
control_profiles = saved.get('controlProfiles', [])
|
control_profiles = saved.get('controlProfiles', [])
|
||||||
distinct_windows = saved.get('distinctWindows', {})
|
distinct_windows = saved.get('distinctWindows', {})
|
||||||
published_windows = saved.get('publishedWindows', 0)
|
published_windows = saved.get('publishedWindows', 0)
|
||||||
|
last_probe_steps = saved.get('lastProbeSteps', 0)
|
||||||
|
learning_probe = saved.get('learningProbe')
|
||||||
cpu_started = time.process_time()
|
cpu_started = time.process_time()
|
||||||
decode_started = time.monotonic()
|
decode_started = time.monotonic()
|
||||||
dataset_stats = []
|
dataset_stats = []
|
||||||
@@ -113,7 +116,7 @@ def main():
|
|||||||
held_out = is_held_out(session_id)
|
held_out = is_held_out(session_id)
|
||||||
try:
|
try:
|
||||||
directory = root / 'recordings' / session_id
|
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
|
seen += 1
|
||||||
# Keep evaluation naturally distributed. Training reserves space
|
# Keep evaluation naturally distributed. Training reserves space
|
||||||
# for recorded commands and releases so periods without commands
|
# for recorded commands and releases so periods without commands
|
||||||
@@ -137,15 +140,15 @@ def main():
|
|||||||
if cancelled():
|
if cancelled():
|
||||||
break # Do not call a partially decoded recording validated.
|
break # Do not call a partially decoded recording validated.
|
||||||
if not samples:
|
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')
|
raise ValueError('No complete, aligned observation/action windows')
|
||||||
# Entire sessions stay in one partition across all jobs. Neighboring
|
# Entire sessions stay in one partition across all jobs. Neighboring
|
||||||
# video windows must never straddle training and validation.
|
# video windows must never straddle training and validation.
|
||||||
held_out = is_held_out(session_id)
|
held_out = is_held_out(session_id)
|
||||||
if held_out:
|
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)
|
validation.extend(samples)
|
||||||
else:
|
else:
|
||||||
schema = candidate_schema
|
schema = candidate_schema
|
||||||
@@ -171,15 +174,16 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
decode_seconds = time.monotonic() - decode_started
|
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()
|
train_started = time.monotonic()
|
||||||
numeric_mask = torch.zeros(SLOTS, VALUES)
|
gradient_norms = []
|
||||||
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()
|
|
||||||
losses = []
|
losses = []
|
||||||
model.train()
|
model.train()
|
||||||
for _ in range(config['passesPerJob']):
|
for _ in range(config['passesPerJob']):
|
||||||
@@ -188,20 +192,18 @@ def main():
|
|||||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||||
break
|
break
|
||||||
batch = pool[offset:offset + config['batchSize']]
|
batch = pool[offset:offset + config['batchSize']]
|
||||||
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
images, states, targets, values, available = tensors(batch)
|
||||||
images = images.float().div_(255)
|
|
||||||
optimizer.zero_grad(set_to_none=True)
|
optimizer.zero_grad(set_to_none=True)
|
||||||
action_logits, predictions = model(images, states)
|
action_logits, predictions = model(images, states)
|
||||||
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
|
event_loss, value_loss = policy_losses(action_logits, predictions, targets, values, available, numeric_mask, positive_weight)
|
||||||
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)
|
|
||||||
loss = event_loss + value_loss
|
loss = event_loss + value_loss
|
||||||
if not torch.isfinite(loss):
|
if not torch.isfinite(loss):
|
||||||
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
||||||
loss.backward()
|
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()
|
optimizer.step()
|
||||||
steps += 1
|
steps += 1
|
||||||
examples += len(batch)
|
examples += len(batch)
|
||||||
@@ -219,89 +221,65 @@ def main():
|
|||||||
evaluation_started = time.monotonic()
|
evaluation_started = time.monotonic()
|
||||||
distinct_windows.update(observed_windows)
|
distinct_windows.update(observed_windows)
|
||||||
unique_count = sum(distinct_windows.values())
|
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():
|
if validation and not cancelled():
|
||||||
model.eval()
|
measured = evaluate(model, validation, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
|
||||||
true_positive = predicted_positive = actual_positive = 0
|
evaluated = measured['windows']
|
||||||
value_error = value_count = evaluated = 0
|
precision, recall = measured.get('precision', 0), measured.get('recall', 0)
|
||||||
stop_actual = stop_detected = false_stops = 0
|
actual_positive = measured.get('positiveActions', 0)
|
||||||
drive_actual = drive_detected = 0
|
mse = measured.get('numericMse', 0)
|
||||||
empty_windows = unwanted_windows = 0
|
stop_actual = measured.get('stopEvents', 0)
|
||||||
stop_slots = [slot for slot, descriptor in enumerate(schema) if is_stop(descriptor)]
|
stop_recall = measured.get('stopRecall', 0)
|
||||||
drive_slots = [slot for slot, descriptor in enumerate(schema)
|
drive_actual = measured.get('movementEvents', 0)
|
||||||
if descriptor['template']['type'] == 'drive' and not is_stop(descriptor)]
|
drive_recall = measured.get('movementRecall', 0)
|
||||||
per_command = [{'slot': slot, 'command': descriptor['template'], 'actual': 0,
|
frequency_f1 = max((row['f1'] for row in measured.get('baselines', {}).get('trainingFrequency', [])), default=0)
|
||||||
'predicted': 0, 'matched': 0} for slot, descriptor in enumerate(schema)]
|
|
||||||
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 = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
|
||||||
logits, predictions = model(images.float() / 255, states)
|
|
||||||
predicted = torch.zeros_like(targets, dtype=torch.bool)
|
|
||||||
# Use the same threshold and competing-command selection as live
|
|
||||||
# inference. Reservoir samples cannot assess temporal latching or
|
|
||||||
# closed-loop motion; report this explicitly rather than implying it.
|
|
||||||
for row, scores in enumerate(logits.sigmoid().tolist()):
|
|
||||||
for slot in select_slots(scores, schema, job['actionThreshold']):
|
|
||||||
predicted[row, slot] = True
|
|
||||||
actual = targets.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()
|
|
||||||
empty = ~actual.any(dim=1)
|
|
||||||
empty_windows += empty.sum().item()
|
|
||||||
unwanted_windows += (empty & predicted.any(dim=1)).sum().item()
|
|
||||||
if stop_slots:
|
|
||||||
stop_actual += actual[:, stop_slots].sum().item()
|
|
||||||
stop_detected += (actual[:, stop_slots] & predicted[:, stop_slots]).sum().item()
|
|
||||||
false_stops += (predicted[:, stop_slots] & ~actual[:, stop_slots]).sum().item()
|
|
||||||
if drive_slots:
|
|
||||||
drive_actual += actual[:, drive_slots].sum().item()
|
|
||||||
drive_detected += (actual[:, drive_slots] & predicted[:, drive_slots]).sum().item()
|
|
||||||
for item in per_command:
|
|
||||||
slot = item['slot']
|
|
||||||
item['actual'] += actual[:, slot].sum().item()
|
|
||||||
item['predicted'] += predicted[:, slot].sum().item()
|
|
||||||
item['matched'] += (actual[:, slot] & predicted[:, slot]).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)
|
|
||||||
stop_recall = stop_detected / max(1, stop_actual)
|
|
||||||
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
||||||
gate_reasons = []
|
gate_reasons = []
|
||||||
for condition, reason in [
|
for condition, reason in [
|
||||||
|
(unsupported_events == 0, 'Held-out controls are not fully represented; see unsupportedCommands'),
|
||||||
(evaluated == len(validation), 'Evaluation interrupted'),
|
(evaluated == len(validation), 'Evaluation interrupted'),
|
||||||
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
||||||
(evaluated >= 128, 'Need 128 held-out windows'),
|
(evaluated >= 128, 'Need 128 held-out windows'),
|
||||||
(actual_positive >= 32, 'Need 32 held-out command events'),
|
(actual_positive >= 32, 'Need 32 held-out command events'),
|
||||||
(stop_actual >= 8, 'Need eight held-out stop events'),
|
(stop_actual >= 8, 'Need eight held-out stop events'),
|
||||||
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
||||||
(drive_detected / max(1, drive_actual) >= .5, 'Movement command recall below 0.5'),
|
(drive_recall >= .5, 'Movement command recall below 0.5'),
|
||||||
(precision >= .5, 'Command precision below 0.5'),
|
(precision >= .5, 'Command precision below 0.5'),
|
||||||
(recall >= .5, 'Command recall below 0.5'),
|
(recall >= .5, 'Command recall below 0.5'),
|
||||||
(stop_recall >= .5, 'Stop recall below 0.5'),
|
(stop_recall >= .5, 'Stop recall below 0.5'),
|
||||||
(value_count == 0 or mse <= .1, 'Command parameter error above 0.1'),
|
(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:
|
if not condition:
|
||||||
gate_reasons.append(reason)
|
gate_reasons.append(reason)
|
||||||
evaluation = {'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
|
evaluation = {**measured, 'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
|
||||||
'method': 'Held-out 100ms command events; not a closed-loop rollout',
|
'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,
|
'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions,
|
||||||
'windows': evaluated, 'precision': precision, 'recall': recall,
|
'gateReasons': gate_reasons}
|
||||||
'numericMse': mse, 'positiveActions': actual_positive,
|
after_training = evaluate(model, audit_subset, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
|
||||||
'predictedActions': predicted_positive, 'falsePositiveActions': predicted_positive - true_positive,
|
probe_seconds = 0
|
||||||
'stopEvents': stop_actual, 'missedStops': stop_actual - stop_detected,
|
if not cancelled() and (learning_probe is None or steps - last_probe_steps >= 1000):
|
||||||
'stopRecall': stop_recall, 'falseStops': false_stops,
|
remaining = config['maxJobSeconds'] - (time.monotonic() - started)
|
||||||
'movementEvents': drive_actual, 'movementRecall': drive_detected / max(1, drive_actual),
|
if remaining > 10:
|
||||||
'noCommandWindows': empty_windows, 'unwantedCommandWindows': unwanted_windows,
|
probe_started = time.monotonic()
|
||||||
'commands': per_command, 'gateReasons': gate_reasons}
|
learning_probe = probe(model, audit_subset, schema, job['actionThreshold'], frequencies,
|
||||||
evaluation_seconds = time.monotonic() - evaluation_started
|
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,
|
publication = {'distinctMinutes': unique_count / 600,
|
||||||
'newMinutes': (unique_count - published_windows) / 600,
|
'newMinutes': (unique_count - published_windows) / 600,
|
||||||
'minimumMinutes': config['minimumDrivingMinutes'],
|
'minimumMinutes': config['minimumDrivingMinutes'],
|
||||||
@@ -354,9 +332,9 @@ def main():
|
|||||||
'schema': schema, 'steps': steps, 'examples': examples,
|
'schema': schema, 'steps': steps, 'examples': examples,
|
||||||
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
|
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
|
||||||
'controlProfiles': control_profiles, 'distinctWindows': distinct_windows,
|
'controlProfiles': control_profiles, 'distinctWindows': distinct_windows,
|
||||||
'publishedWindows': published_windows,
|
'publishedWindows': published_windows, 'lastProbeSteps': last_probe_steps, 'learningProbe': learning_probe,
|
||||||
}, resume_path)
|
}, 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,
|
'trainingSeconds': training_seconds, 'evaluationSeconds': evaluation_seconds, 'checkpointSeconds': time.monotonic() - save_started,
|
||||||
'cpuSeconds': time.process_time() - cpu_started,
|
'cpuSeconds': time.process_time() - cpu_started,
|
||||||
'peakRssMiB': resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
|
'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);
|
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stopDockGuard,
|
||||||
upsertRover,
|
upsertRover,
|
||||||
removeRover,
|
removeRover,
|
||||||
lockRover,
|
lockRover,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function createRosterLifecycle(deps) {
|
|||||||
offline, while removeRoverDrivers still receives the captured record so
|
offline, while removeRoverDrivers still receives the captured record so
|
||||||
it can clean the reverse membership index and Socket.IO room membership.
|
it can clean the reverse membership index and Socket.IO room membership.
|
||||||
*/
|
*/
|
||||||
|
require('../dockingService').disconnect(record);
|
||||||
rovers.delete(id);
|
rovers.delete(id);
|
||||||
removeRoverDrivers(id, record);
|
removeRoverDrivers(id, record);
|
||||||
stopDockGuard(id);
|
stopDockGuard(id);
|
||||||
|
|||||||
@@ -389,6 +389,10 @@ function createSensorPipeline(deps) {
|
|||||||
|
|
||||||
function evaluateDockGuard(record, sensors) {
|
function evaluateDockGuard(record, sensors) {
|
||||||
if (!record || !sensors) return;
|
if (!record || !sensors) return;
|
||||||
|
if (sensors.docking?.phase === 'undocking' || sensors.docking?.phase === 'assisting') {
|
||||||
|
stopDockGuard(record.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = getDockGuardState(record.id);
|
const state = getDockGuardState(record.id);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
@@ -564,6 +568,10 @@ function createSensorPipeline(deps) {
|
|||||||
};
|
};
|
||||||
record.lastSensor = { raw: frame, decoded };
|
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
|
Rover manager remains responsible only for decoding and routing sensor
|
||||||
frames. The dedicated service receives the completed sensor object after
|
frames. The dedicated service receives the completed sensor object after
|
||||||
@@ -577,6 +585,7 @@ function createSensorPipeline(deps) {
|
|||||||
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
||||||
? processOvercurrentTelemetry(roverId, decoded)
|
? processOvercurrentTelemetry(roverId, decoded)
|
||||||
: null;
|
: null;
|
||||||
|
record.lastOvercurrentProtection = overcurrentProtection;
|
||||||
updateMovement(record, decoded);
|
updateMovement(record, decoded);
|
||||||
const hasDockInfo = decoded?.chargingSources != null;
|
const hasDockInfo = decoded?.chargingSources != null;
|
||||||
if (hasDockInfo) {
|
if (hasDockInfo) {
|
||||||
@@ -589,6 +598,10 @@ function createSensorPipeline(deps) {
|
|||||||
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||||
handlePrivateButtonHold(record, decoded);
|
handlePrivateButtonHold(record, decoded);
|
||||||
evaluatePrivateSafety(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', {
|
io.to(record.room).volatile.emit('sensorFrame', {
|
||||||
roverId,
|
roverId,
|
||||||
frame,
|
frame,
|
||||||
|
|||||||
@@ -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
|
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
|
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. add admin ui for VIP and private requests instead of only through discord
|
||||||
1. one concise message which contains the status of all the rovers
|
2. make roverd self update checkout to main branch
|
||||||
2. bot clears the channel, then sends a new message
|
3. fix this:
|
||||||
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:
|
|
||||||
`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]: /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]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function deriveDriveDockStateFromTelemetry(dockTelemetry) {
|
|||||||
const oiNormalized = oiLabel.toLowerCase();
|
const oiNormalized = oiLabel.toLowerCase();
|
||||||
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
|
const chargingLabel = dockTelemetry?.chargingStateLabel || '';
|
||||||
const docked = Boolean(dockTelemetry?.homeBase);
|
const docked = Boolean(dockTelemetry?.homeBase);
|
||||||
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
const charging = dockTelemetry?.dockingPhase === 'charging';
|
||||||
const driving = oiNormalized === 'full';
|
const driving = oiNormalized === 'full' && !dockTelemetry?.movementLocked;
|
||||||
const dockedNotCharging = docked && !charging;
|
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 };
|
return { driving, docked, charging, dockedNotCharging, dockingInProgress, oiLabel, chargingLabel };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// Drive Dock Action
|
// Drive Dock Action
|
||||||
// Purpose: Defines the Drive Dock Action module and the local helpers/components used in this file.
|
// 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.
|
// 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 '../MobileControls/mobileControls.css';
|
||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
import ControlHint from '../ControlHint/index.jsx';
|
import ControlHint from '../ControlHint/index.jsx';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
import { deriveDriveDockStateFromTelemetry } from './driveDockState.js';
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||||
|
|
||||||
@@ -91,14 +90,13 @@ export default function DriveDockAction({
|
|||||||
const isMobile = layout === 'mobile';
|
const isMobile = layout === 'mobile';
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const actions = useControlActions();
|
const actions = useControlActions();
|
||||||
const dockAssist = useManualDockAssist();
|
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
|
const state = driveDockState ?? deriveDriveDockStateFromTelemetry(dockTelemetry);
|
||||||
const { driving, docked, charging, dockingInProgress } = state;
|
const { driving, docked, charging, dockingInProgress } = state;
|
||||||
const [pending, setPending] = useState(null);
|
const pending = dockTelemetry.dockingPhase === 'undocking' ? 'drive' : null;
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const manualAssistActive = Boolean(dockAssist.active);
|
const manualAssistActive = Boolean(dockTelemetry.cameraLocked);
|
||||||
|
|
||||||
const driveDisabled = !roverId || pending !== null;
|
const driveDisabled = !roverId || pending !== null;
|
||||||
const dockDisabled = !roverId || pending !== null;
|
const dockDisabled = !roverId || pending !== null;
|
||||||
@@ -124,64 +122,37 @@ export default function DriveDockAction({
|
|||||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
||||||
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
||||||
|
|
||||||
const handleReturnToDrive = async () => {
|
const handleStartDrive = () => {
|
||||||
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 () => {
|
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
if (isMobile) triggerTouchHaptic('button');
|
if (isMobile) triggerTouchHaptic('button');
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setPending('drive');
|
actions.undock();
|
||||||
try {
|
|
||||||
dockAssist.exitAssist();
|
|
||||||
actions.setMode('drive');
|
|
||||||
await actions.runMacro('drive-sequence');
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
setPending(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmDock = async () => {
|
const handleConfirmDock = () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
if (isMobile) triggerTouchHaptic('button');
|
if (isMobile) triggerTouchHaptic('button');
|
||||||
setPending('dock');
|
actions.sendCommand('enterDocking');
|
||||||
try {
|
setConfirmOpen(false);
|
||||||
dockAssist.enterAssist();
|
setShowModal(false);
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
setPending(null);
|
|
||||||
setConfirmOpen(false);
|
|
||||||
setShowModal(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDock = () => {
|
const handleOpenDock = () => {
|
||||||
if (dockDisabled) return;
|
if (dockDisabled) return;
|
||||||
if (isMobile) triggerTouchHaptic('button');
|
if (isMobile) triggerTouchHaptic('button');
|
||||||
if (manualAssistActive) {
|
if (manualAssistActive) {
|
||||||
dockAssist.exitAssist();
|
actions.sendCommand('cancelDocking');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setConfirmOpen(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
|
// 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
|
// tapped repeatedly; attach mobile touch suppression directly to the card so
|
||||||
// Safari does not select labels or open callouts during those interactions.
|
// Safari does not select labels or open callouts during those interactions.
|
||||||
@@ -254,7 +225,7 @@ export default function DriveDockAction({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={driveDisabled}
|
disabled={driveDisabled}
|
||||||
onClick={handleReturnToDrive}
|
onClick={handleStartDrive}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
className={`${baseCardClasses} ${filledHeight} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${amberCta}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
|||||||
mobileHud={mobileHud}
|
mobileHud={mobileHud}
|
||||||
labelScale={1}
|
labelScale={1}
|
||||||
/>
|
/>
|
||||||
<ManualDockAssistOverlay mobileHud={mobileHud} />
|
<ManualDockAssistOverlay roverId={roverId} mobileHud={mobileHud} />
|
||||||
<HudChatInput compact={mobileHud} />
|
<HudChatInput compact={mobileHud} />
|
||||||
<OvercurrentOverlay compact={mobileHud} />
|
<OvercurrentOverlay compact={mobileHud} />
|
||||||
<LowBatteryOverlay compact={mobileHud} />
|
<LowBatteryOverlay compact={mobileHud} />
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const ACTIONS = [
|
|||||||
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
|
{ id: 'allAux', label: 'All cleaning motors', kind: 'button', section: 'Aux buttons' },
|
||||||
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
{ id: 'mainReverse', label: 'Main reverse toggle', kind: 'button', section: 'Brush toggles' },
|
||||||
{ id: 'sideReverse', label: 'Side 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: 'dockMacro', label: 'Manual docking assist', kind: 'button', section: 'Mode controls' },
|
||||||
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
{ id: 'headlightToggle', label: 'Headlight toggle', kind: 'button', section: 'Camera' },
|
||||||
{ id: 'laserToggle', label: 'Laser 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
|
// Translucent fills preserve text contrast; an amber border still identifies
|
||||||
// locked tiles regardless of the admin's chosen color.
|
// locked tiles regardless of the admin's chosen color.
|
||||||
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
|
const color = /^#[0-9a-f]{6}$/i.test(entity.color) ? entity.color : '#3b82f6';
|
||||||
const Control = controls[entity.type] || ReadOnlyControl;
|
// Ordinary users see only the value; admins retain the entity's actual input.
|
||||||
const disabled = !connected || !entity.available || !allowed || (entity.locked && !admin);
|
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"
|
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` }}>
|
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` }}>
|
<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` }}>
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
|
||||||
|
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
|
|
||||||
function ManualDockAssistOverlay({ mobileHud = false }) {
|
function ManualDockAssistOverlay({ roverId, mobileHud = false }) {
|
||||||
const { visible, statusLabel, statusTone, active, charging } = useManualDockAssist({ manageLifecycle: true });
|
const telemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
|
const active = telemetry.cameraLocked;
|
||||||
|
const charging = telemetry.dockingPhase === 'charging';
|
||||||
|
const visible = active || telemetry.homeBase;
|
||||||
|
const statusLabel = charging ? 'Docked and charging' : telemetry.homeBase ? 'Docked' : 'Docking assist active';
|
||||||
|
const statusTone = charging ? 'good' : telemetry.homeBase ? 'warn' : 'active';
|
||||||
const [popupMessage, setPopupMessage] = useState('');
|
const [popupMessage, setPopupMessage] = useState('');
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
const prevActiveRef = useRef(active);
|
const [previousActive, setPreviousActive] = useState(active);
|
||||||
|
|
||||||
useEffect(() => {
|
// These messages are presentation only; telemetry owns the operation itself.
|
||||||
const prevActive = prevActiveRef.current;
|
if (previousActive !== active) {
|
||||||
const enabledNow = active && !prevActive;
|
setPreviousActive(active);
|
||||||
const autoDisabledOnCharge = !active && prevActive && charging;
|
if (active) setPopupMessage('Dock assist mode enabled');
|
||||||
if (enabledNow) {
|
else if (charging) setPopupMessage('Docking successful! Thank you!');
|
||||||
setPopupMessage('Dock assist mode enabled');
|
}
|
||||||
} else if (autoDisabledOnCharge) {
|
|
||||||
setPopupMessage('Docking successful! Thank you!');
|
|
||||||
}
|
|
||||||
prevActiveRef.current = active;
|
|
||||||
}, [active, charging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!popupMessage) return undefined;
|
if (!popupMessage) return undefined;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||||
|
import { selectDockTelemetry, dockTelemetryEqual } from '../../../../context/telemetryViews.js';
|
||||||
// Bottom-right Corner Pod
|
// Bottom-right Corner Pod
|
||||||
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
|
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
@@ -31,7 +33,7 @@ export default function BottomRightPod({ roverId }) {
|
|||||||
const layout = useLayout();
|
const layout = useLayout();
|
||||||
const [open, setOpen] = usePodVisibility('camera', true);
|
const [open, setOpen] = usePodVisibility('camera', true);
|
||||||
const camera = useControlSelector((control) => control.state.camera);
|
const camera = useControlSelector((control) => control.state.camera);
|
||||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
const { cameraLocked } = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const { setServoAngle } = useControlActions();
|
const { setServoAngle } = useControlActions();
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
const config = camera?.config;
|
const config = camera?.config;
|
||||||
@@ -47,7 +49,7 @@ export default function BottomRightPod({ roverId }) {
|
|||||||
const cameraPodOpen = showCameraControls && enabled && open;
|
const cameraPodOpen = showCameraControls && enabled && open;
|
||||||
|
|
||||||
const updateFromPointer = useCallback((event) => {
|
const updateFromPointer = useCallback((event) => {
|
||||||
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
|
if (!canControl || !enabled || cameraLocked || !(max > min)) return;
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
|
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
|
||||||
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
|
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
|
||||||
@@ -64,7 +66,7 @@ export default function BottomRightPod({ roverId }) {
|
|||||||
}
|
}
|
||||||
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
|
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
|
||||||
setServoAngle(min + nextFraction * (max - min));
|
setServoAngle(min + nextFraction * (max - min));
|
||||||
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
|
}, [canControl, cameraLocked, enabled, max, min, setServoAngle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useControlActions } from '../../../../controls/index.js';
|
|||||||
import ControlHint from '../../../ControlHint/index.jsx';
|
import ControlHint from '../../../ControlHint/index.jsx';
|
||||||
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/telemetryViews.js';
|
||||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
@@ -253,9 +252,6 @@ export default function DockingHud({ roverId }) {
|
|||||||
const layout = useLayout();
|
const layout = useLayout();
|
||||||
const actions = useControlActions();
|
const actions = useControlActions();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
|
||||||
// success sounds, camera positioning, speed cap, and automatic exit after charging begins.
|
|
||||||
const dockAssist = useManualDockAssist({ manageLifecycle: true });
|
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
const batteryState = useSessionSelector((state) => {
|
const batteryState = useSessionSelector((state) => {
|
||||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||||
@@ -268,15 +264,14 @@ export default function DockingHud({ roverId }) {
|
|||||||
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
|
const [dockExpansionOpen, setDockExpansionOpen] = usePodVisibility('dockAssist', true);
|
||||||
// The action name is presentation state as well as busy state. Keeping the
|
// The action name is presentation state as well as busy state. Keeping the
|
||||||
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
|
// reason prevents a passive, already-undocked rover from ever saying "Undocking".
|
||||||
const [pendingAction, setPendingAction] = useState(null);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [showUndockTransition, setShowUndockTransition] = useState(false);
|
const [showUndockTransition, setShowUndockTransition] = useState(false);
|
||||||
|
|
||||||
const docked = Boolean(dockTelemetry?.homeBase);
|
const docked = Boolean(dockTelemetry?.homeBase);
|
||||||
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
|
const oiMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase();
|
||||||
// The established UI contract treats exactly passive + undocked as the Roomba's
|
// The established UI contract treats exactly passive + undocked as the Roomba's
|
||||||
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
|
// autonomous docking attempt. Unknown telemetry must not fabricate that state.
|
||||||
const autoDocking = !docked && !dockAssist.active && oiMode === 'passive';
|
const autoDocking = !docked && !dockTelemetry.cameraLocked && oiMode === 'passive';
|
||||||
|
const pendingAction = (dockTelemetry.dockingPhase === 'undocking') ? (docked ? 'undocking' : 'resuming') : null;
|
||||||
const pending = pendingAction !== null;
|
const pending = pendingAction !== null;
|
||||||
/* Mobile already presents its own touch-oriented driving controls. The docked
|
/* Mobile already presents its own touch-oriented driving controls. The docked
|
||||||
action therefore keeps its plain-language instruction without advertising a
|
action therefore keeps its plain-language instruction without advertising a
|
||||||
@@ -287,55 +282,22 @@ export default function DockingHud({ roverId }) {
|
|||||||
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
|
// The camera arc is the shared circular-pod reference size. Keep the dock expansion flush
|
||||||
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
|
// against the battery shell after enlarging that gauge to the same 8.5-rem footprint.
|
||||||
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
|
const cornerOffsetClass = batteryPodOpen ? 'right-34' : 'right-0';
|
||||||
const previousDockedRef = useRef(docked);
|
const [previousDocked, setPreviousDocked] = useState(docked);
|
||||||
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
|
const finishUndockTransition = useCallback(() => setShowUndockTransition(false), []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Presentation-only transition; mounting with an undocked rover does not animate.
|
||||||
const wasDocked = previousDockedRef.current;
|
if (previousDocked !== docked) {
|
||||||
// Only a real live transition plays the cue. An already-undocked rover must not animate
|
setPreviousDocked(docked);
|
||||||
// merely because the user opened or refreshed the page.
|
setShowUndockTransition(!docked);
|
||||||
if (wasDocked && !docked) {
|
}
|
||||||
setShowUndockTransition(true);
|
|
||||||
} else if (docked) {
|
|
||||||
setShowUndockTransition(false);
|
|
||||||
}
|
|
||||||
previousDockedRef.current = docked;
|
|
||||||
}, [docked]);
|
|
||||||
|
|
||||||
const startDriving = async (action) => {
|
const startDriving = () => {
|
||||||
if (!roverId || pending || !canControl) return;
|
if (!roverId || pending || !canControl) return;
|
||||||
setPendingAction(action);
|
actions.undock();
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
// The established drive sequence is the canonical undock path: it restores the camera,
|
|
||||||
// enters full Open Interface mode, and performs the short physical back-away from the dock.
|
|
||||||
dockAssist.exitAssist();
|
|
||||||
actions.setMode('drive');
|
|
||||||
await actions.runMacro('drive-sequence');
|
|
||||||
} catch (caughtError) {
|
|
||||||
setError(caughtError?.message || 'Unable to start driving. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startUndocking = () => {
|
|
||||||
startDriving('undocking');
|
|
||||||
};
|
|
||||||
const resumeDriving = () => {
|
|
||||||
startDriving('resuming');
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDocking = () => {
|
const startDocking = () => {
|
||||||
if (!roverId || pending || !canControl) return;
|
if (!roverId || pending || !canControl) return;
|
||||||
setError('');
|
actions.sendCommand('enterDocking');
|
||||||
try {
|
|
||||||
// Enter on the first click. The explanation appears as the resulting active state instead
|
|
||||||
// of forcing the user through a modal and a second confirmation action.
|
|
||||||
dockAssist.enterAssist();
|
|
||||||
} catch (caughtError) {
|
|
||||||
setError(caughtError?.message || 'Unable to start dock assist. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -358,20 +320,20 @@ export default function DockingHud({ roverId }) {
|
|||||||
driveKeyLabel={driveKeyLabel}
|
driveKeyLabel={driveKeyLabel}
|
||||||
pending={pendingAction === 'undocking'}
|
pending={pendingAction === 'undocking'}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
error={error}
|
error={dockTelemetry.dockingError}
|
||||||
onUndock={startUndocking}
|
onUndock={startDriving}
|
||||||
/>
|
/>
|
||||||
) : autoDocking || pendingAction === 'resuming' ? (
|
) : autoDocking || pendingAction === 'resuming' ? (
|
||||||
<AutoDockingAction
|
<AutoDockingAction
|
||||||
driveKeyLabel={driveKeyLabel}
|
driveKeyLabel={driveKeyLabel}
|
||||||
pending={pendingAction === 'resuming'}
|
pending={pendingAction === 'resuming'}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
error={error}
|
error={dockTelemetry.dockingError}
|
||||||
onResumeDriving={resumeDriving}
|
onResumeDriving={startDriving}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{dockAssist.active ? (
|
{dockTelemetry.cameraLocked ? (
|
||||||
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
|
/* Dock assist needs the unobscured camera image. A thin cyan frame communicates the
|
||||||
temporary mode without adding another instruction surface over the video. */
|
temporary mode without adding another instruction surface over the video. */
|
||||||
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
|
<div className="pointer-events-none absolute inset-0 z-50 ring-8 ring-inset ring-cyan-200/90" aria-hidden="true" />
|
||||||
@@ -379,15 +341,15 @@ export default function DockingHud({ roverId }) {
|
|||||||
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
|
{/* Desktop keeps the compact corner entry point. Mobile starts assist from
|
||||||
its dedicated control column, but once active it still mounts this component
|
its dedicated control column, but once active it still mounts this component
|
||||||
so the centered camera instruction and cancel action remain available. */}
|
so the centered camera instruction and cancel action remain available. */}
|
||||||
{layout === 'desktop' || dockAssist.active ? (
|
{layout === 'desktop' || dockTelemetry.cameraLocked || dockTelemetry.dockingError ? (
|
||||||
<DockAssistAction
|
<DockAssistAction
|
||||||
active={dockAssist.active}
|
active={dockTelemetry.cameraLocked}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
controlsDisabled={!canControl}
|
controlsDisabled={!canControl}
|
||||||
error={error}
|
error={dockTelemetry.dockingError}
|
||||||
dockKeyLabel={dockKeyLabel}
|
dockKeyLabel={dockKeyLabel}
|
||||||
onDock={startDocking}
|
onDock={startDocking}
|
||||||
onCancel={dockAssist.exitAssist}
|
onCancel={() => actions.sendCommand('cancelDocking')}
|
||||||
cornerOffsetClass={cornerOffsetClass}
|
cornerOffsetClass={cornerOffsetClass}
|
||||||
open={dockExpansionOpen}
|
open={dockExpansionOpen}
|
||||||
onOpenChange={setDockExpansionOpen}
|
onOpenChange={setDockExpansionOpen}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import './mobileControls.css';
|
|||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||||
import HornControl from '../HornControl/index.jsx';
|
import HornControl from '../HornControl/index.jsx';
|
||||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||||
@@ -33,7 +32,6 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
|||||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||||
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
const { setServoAngle, setHeadlight, setLaser, setAuxMotors, startHorn, stopHorn } = useControlActions();
|
||||||
const dockAssist = useManualDockAssist();
|
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
// Turn ownership is the common mutation boundary for every control in this
|
// Turn ownership is the common mutation boundary for every control in this
|
||||||
@@ -44,7 +42,8 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
|||||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||||
const vacuumDisabled = controlsDisabled
|
const vacuumDisabled = controlsDisabled
|
||||||
|| docked
|
|| docked
|
||||||
|| (!drivingMode && !dockAssist.active);
|
|| dockTelemetry.movementLocked
|
||||||
|
|| (!drivingMode && !dockTelemetry.cameraLocked);
|
||||||
const activeAuxButtonRef = useRef(null);
|
const activeAuxButtonRef = useRef(null);
|
||||||
const cameraConfig = camera?.config;
|
const cameraConfig = camera?.config;
|
||||||
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
const cameraEnabled = Boolean(roverId && camera?.enabled && cameraConfig);
|
||||||
@@ -60,7 +59,7 @@ function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
|||||||
: typeof cameraConfig?.homeAngle === 'number'
|
: typeof cameraConfig?.homeAngle === 'number'
|
||||||
? cameraConfig.homeAngle
|
? cameraConfig.homeAngle
|
||||||
: (cameraMin + cameraMax) / 2;
|
: (cameraMin + cameraMax) / 2;
|
||||||
const cameraDisabled = Boolean(controlsDisabled || dockAssist.cameraLocked);
|
const cameraDisabled = Boolean(controlsDisabled || dockTelemetry.cameraLocked);
|
||||||
/*
|
/*
|
||||||
The mobile tilt track shares the same precision flag as desktop tilt. This
|
The mobile tilt track shares the same precision flag as desktop tilt. This
|
||||||
keeps the servo fine-step behavior tied to the selected movement mode rather
|
keeps the servo fine-step behavior tied to the selected movement mode rather
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
// Purpose: Assembles the mobile movement column, which is the right column by default.
|
||||||
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
|
// Scope: Owns movement availability while the in-video DockingHud owns every dock/undock action.
|
||||||
import { FaChargingStation } from 'react-icons/fa';
|
import { FaChargingStation } from 'react-icons/fa';
|
||||||
import { useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||||
import ControlPadPanel from './ControlPadPanel.jsx';
|
import ControlPadPanel from './ControlPadPanel.jsx';
|
||||||
|
|
||||||
function MovementColumnContent({ layout }) {
|
function MovementColumnContent({ layout }) {
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
|
const actions = useControlActions();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const dockAssist = useManualDockAssist();
|
|
||||||
const canControl = useCanControlRover(roverId);
|
const canControl = useCanControlRover(roverId);
|
||||||
const batteryState = useSessionSelector((state) => {
|
const batteryState = useSessionSelector((state) => {
|
||||||
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
|
||||||
@@ -25,20 +24,11 @@ function MovementColumnContent({ layout }) {
|
|||||||
const docked = Boolean(dockTelemetry?.homeBase);
|
const docked = Boolean(dockTelemetry?.homeBase);
|
||||||
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
const drivingMode = String(dockTelemetry?.oiModeLabel || '').toLowerCase() === 'full';
|
||||||
|
|
||||||
/*
|
|
||||||
DockingHud is now the only place where a user starts driving or enters docking
|
|
||||||
assist. This column only decides whether touch movement is safe. Requiring both
|
|
||||||
an undocked rover and full OI mode keeps the pad inert throughout the undock
|
|
||||||
macro, including the interval where dock contact and OI mode change separately.
|
|
||||||
|
|
||||||
Manual assist is the deliberate exception to the normal OI-mode requirement:
|
|
||||||
its entire purpose is to let the user drive onto the dock under the assist speed
|
|
||||||
cap. Actual dock contact still disables movement immediately.
|
|
||||||
*/
|
|
||||||
const movementDisabled = !roverId
|
const movementDisabled = !roverId
|
||||||
|| !canControl
|
|| !canControl
|
||||||
|| docked
|
|| docked
|
||||||
|| (!drivingMode && !dockAssist.active);
|
|| dockTelemetry.movementLocked
|
||||||
|
|| (!drivingMode && !dockTelemetry.cameraLocked);
|
||||||
// Both controls share the same hardware-availability boundary. Dock assist
|
// Both controls share the same hardware-availability boundary. Dock assist
|
||||||
// changes their actions, not who is allowed to command the rover.
|
// changes their actions, not who is allowed to command the rover.
|
||||||
const dockActionDisabled = movementDisabled;
|
const dockActionDisabled = movementDisabled;
|
||||||
@@ -49,10 +39,10 @@ function MovementColumnContent({ layout }) {
|
|||||||
// Mobile deliberately enters assist on the first tap. The centered video HUD
|
// Mobile deliberately enters assist on the first tap. The centered video HUD
|
||||||
// provides the resulting instruction, so a confirmation modal would only add
|
// provides the resulting instruction, so a confirmation modal would only add
|
||||||
// friction between intent and the camera-guided docking task.
|
// friction between intent and the camera-guided docking task.
|
||||||
if (dockAssist.active) {
|
if (dockTelemetry.cameraLocked) {
|
||||||
dockAssist.exitAssist();
|
actions.sendCommand('cancelDocking');
|
||||||
} else {
|
} else {
|
||||||
dockAssist.enterAssist();
|
actions.sendCommand('enterDocking');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +53,7 @@ function MovementColumnContent({ layout }) {
|
|||||||
disabled={dockActionDisabled}
|
disabled={dockActionDisabled}
|
||||||
onClick={handleDockAction}
|
onClick={handleDockAction}
|
||||||
className={`mobile-touch-control flex min-h-18 shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
className={`mobile-touch-control flex min-h-18 shrink-0 items-center justify-center gap-1.5 rounded-xl border-2 px-2 text-base font-semibold shadow-md transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
dockAssist.active
|
dockTelemetry.cameraLocked
|
||||||
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
? 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||||
: batteryUrgent
|
: batteryUrgent
|
||||||
? 'border-red-300/80 bg-red-950 text-red-50'
|
? 'border-red-300/80 bg-red-950 text-red-50'
|
||||||
@@ -75,7 +65,7 @@ function MovementColumnContent({ layout }) {
|
|||||||
<FaChargingStation className="text-lg" aria-hidden="true" />
|
<FaChargingStation className="text-lg" aria-hidden="true" />
|
||||||
{/* The mobile column remains the only mobile entry point into dock assist. Battery
|
{/* The mobile column remains the only mobile entry point into dock assist. Battery
|
||||||
severity makes that existing action more obvious without adding another control. */}
|
severity makes that existing action more obvious without adding another control. */}
|
||||||
<span>{dockAssist.active ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
<span>{dockTelemetry.cameraLocked ? 'Exit dock assist' : batteryUrgent ? 'Dock now' : batteryLow ? 'Dock and charge soon' : 'Dock and charge'}</span>
|
||||||
</button>
|
</button>
|
||||||
{/* Keeping the pad mounted prevents the mobile columns from changing size while
|
{/* Keeping the pad mounted prevents the mobile columns from changing size while
|
||||||
the centered video HUD explains and performs dock-related transitions. */}
|
the centered video HUD explains and performs dock-related transitions. */}
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ export default function SettingsPanel() {
|
|||||||
<span className="font-semibold text-white">Swap control columns (put joystick on the left)</span>
|
<span className="font-semibold text-white">Swap control columns (put joystick on the left)</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
<CardFrame title="Macros" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Driving" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
<SettingRow inline>
|
<SettingRow inline>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -419,7 +419,7 @@ export default function SettingsPanel() {
|
|||||||
checked={driveMacroBackoffEnabled}
|
checked={driveMacroBackoffEnabled}
|
||||||
onChange={handleDriveMacroBackoffEnabled}
|
onChange={handleDriveMacroBackoffEnabled}
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold text-white">Enable backward bump in drive macro</span>
|
<span className="font-semibold text-white">Back away from the dock when undocking</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Audio" className="col-span-full" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function LearningCard({ roverId }) {
|
|||||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||||
<select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}>
|
<select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}>
|
||||||
{!models.length && <option value="">No published models yet</option>}
|
{!models.length && <option value="">No published models yet</option>}
|
||||||
{models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.experimental ? ' (experimental)' : ''}{model.compatible ? '' : model.formatVersion !== 3 ? ' (older model format)' : ' (incompatible)'}</option>)}
|
{models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.experimental ? ' (experimental)' : ''}{model.compatible ? '' : model.formatVersion !== 5 ? ' (older model format)' : ' (incompatible)'}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}>
|
<button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}>
|
||||||
{chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'}
|
{chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'}
|
||||||
@@ -220,6 +220,10 @@ function LearningCard({ roverId }) {
|
|||||||
<Row label="Latest update status">{training?.publication?.reason || 'Collecting data'}</Row>
|
<Row label="Latest update status">{training?.publication?.reason || 'Collecting data'}</Row>
|
||||||
<Row label="Held-out evaluation">{training?.evaluation?.status || 'Waiting for held-out data'}</Row>
|
<Row label="Held-out evaluation">{training?.evaluation?.status || 'Waiting for held-out data'}</Row>
|
||||||
<Row label="Held-out precision / recall">{training?.evaluation?.precision == null ? 'Unavailable' : `${training.evaluation.precision.toFixed(3)} / ${training.evaluation.recall.toFixed(3)}`}</Row>
|
<Row label="Held-out precision / recall">{training?.evaluation?.precision == null ? 'Unavailable' : `${training.evaluation.precision.toFixed(3)} / ${training.evaluation.recall.toFixed(3)}`}</Row>
|
||||||
|
<Row label="Evaluated training step">{display(training?.evaluatedSteps)}</Row>
|
||||||
|
<Row label="Mean / peak score on human commands">{training?.evaluation?.scoreOnHumanCommands?.mean == null ? 'Unavailable' : `${training.evaluation.scoreOnHumanCommands.mean.toFixed(3)} / ${training.evaluation.scoreOnHumanCommands.max.toFixed(3)}`}</Row>
|
||||||
|
<Row label="Training sample recall before / after">{training?.diagnostics?.trainingAudit?.after?.recall == null ? 'Unavailable' : `${training.diagnostics.trainingAudit.before.recall?.toFixed(3) ?? 'Unavailable'} / ${training.diagnostics.trainingAudit.after.recall.toFixed(3)}`}</Row>
|
||||||
|
<Row label="Memorization probe recall / steps">{training?.diagnostics?.trainingAudit?.learningProbe?.evaluation?.recall == null ? 'Unavailable' : `${training.diagnostics.trainingAudit.learningProbe.evaluation.recall.toFixed(3)} / ${training.diagnostics.trainingAudit.learningProbe.steps}`}</Row>
|
||||||
<Row label="Steps between Latest updates">{display(limits?.training.checkpointEverySteps)}</Row>
|
<Row label="Steps between Latest updates">{display(limits?.training.checkpointEverySteps)}</Row>
|
||||||
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>}
|
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>}
|
||||||
{training?.reason && <p>{training.reason}</p>}
|
{training?.reason && <p>{training.reason}</p>}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const EMPTY_BATTERY_TELEMETRY = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EMPTY_DOCK_TELEMETRY = Object.freeze({
|
const EMPTY_DOCK_TELEMETRY = Object.freeze({
|
||||||
|
dockingPhase: 'idle',
|
||||||
|
movementLocked: true,
|
||||||
|
cameraLocked: false,
|
||||||
|
dockingError: '',
|
||||||
oiModeLabel: 'Unknown',
|
oiModeLabel: 'Unknown',
|
||||||
chargingStateLabel: '',
|
chargingStateLabel: '',
|
||||||
homeBase: false,
|
homeBase: false,
|
||||||
@@ -127,6 +131,10 @@ export function selectDockTelemetry(frame) {
|
|||||||
const sensors = frame?.sensors;
|
const sensors = frame?.sensors;
|
||||||
if (!sensors) return EMPTY_DOCK_TELEMETRY;
|
if (!sensors) return EMPTY_DOCK_TELEMETRY;
|
||||||
return {
|
return {
|
||||||
|
dockingPhase: sensors.docking?.phase || 'idle',
|
||||||
|
movementLocked: sensors.docking?.movementLocked ?? true,
|
||||||
|
cameraLocked: Boolean(sensors.docking?.cameraLocked),
|
||||||
|
dockingError: sensors.docking?.error?.message || '',
|
||||||
oiModeLabel: sensors.oiMode?.label || 'Unknown',
|
oiModeLabel: sensors.oiMode?.label || 'Unknown',
|
||||||
chargingStateLabel: sensors.chargingState?.label || '',
|
chargingStateLabel: sensors.chargingState?.label || '',
|
||||||
// OI packet 34 is the authoritative charging-sources packet. Charging
|
// OI packet 34 is the authoritative charging-sources packet. Charging
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
|||||||
import { useCommandPipeline } from './commandPipeline.js';
|
import { useCommandPipeline } from './commandPipeline.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_KEYMAP,
|
DEFAULT_KEYMAP,
|
||||||
DEFAULT_MACROS,
|
|
||||||
HORN_HEAT_COOL_PER_SEC,
|
HORN_HEAT_COOL_PER_SEC,
|
||||||
HORN_HEAT_RESUME_THRESHOLD,
|
HORN_HEAT_RESUME_THRESHOLD,
|
||||||
HORN_HEAT_UP_PER_SEC,
|
HORN_HEAT_UP_PER_SEC,
|
||||||
HORN_MAX_FREQUENCY,
|
HORN_MAX_FREQUENCY,
|
||||||
HORN_MAX_MS,
|
HORN_MAX_MS,
|
||||||
MANUAL_DOCK_ASSIST_MAX_SPEED,
|
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||||
@@ -32,6 +30,8 @@ import { useSessionActions, useSessionSelector } from '../context/SessionContext
|
|||||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||||
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
||||||
|
import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
|
||||||
|
import { selectDockTelemetry, dockTelemetryEqual } from '../context/telemetryViews.js';
|
||||||
import { trackAnalyticsEvent } from '../analytics/index.js';
|
import { trackAnalyticsEvent } from '../analytics/index.js';
|
||||||
|
|
||||||
const ControlSystemContext = createContext(null);
|
const ControlSystemContext = createContext(null);
|
||||||
@@ -49,7 +49,8 @@ const CONTROL_ACTION_NAMES = [
|
|||||||
'setCameraAxisIntent',
|
'setCameraAxisIntent',
|
||||||
'goServoHome',
|
'goServoHome',
|
||||||
'setCameraPrecisionMode',
|
'setCameraPrecisionMode',
|
||||||
'runMacro',
|
'undock',
|
||||||
|
'sendCommand',
|
||||||
'stopAllMotion',
|
'stopAllMotion',
|
||||||
'sendOiCommand',
|
'sendOiCommand',
|
||||||
'setSensorStream',
|
'setSensorStream',
|
||||||
@@ -60,8 +61,6 @@ const CONTROL_ACTION_NAMES = [
|
|||||||
'updateKeyBinding',
|
'updateKeyBinding',
|
||||||
'resetKeyBindings',
|
'resetKeyBindings',
|
||||||
'registerInputState',
|
'registerInputState',
|
||||||
'setManualDockAssistActive',
|
|
||||||
'toggleManualDockAssist',
|
|
||||||
'setSongNote',
|
'setSongNote',
|
||||||
'sendSong',
|
'sendSong',
|
||||||
'startHorn',
|
'startHorn',
|
||||||
@@ -83,23 +82,6 @@ function clampServoAngle(config, value) {
|
|||||||
return clamp(value, min, max);
|
return clamp(value, min, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDriveSequenceBackoff(steps = []) {
|
|
||||||
if (!Array.isArray(steps) || steps.length < 3) return steps;
|
|
||||||
const last3 = steps.slice(-3);
|
|
||||||
const [backoffStep, pauseStep, stopStep] = last3;
|
|
||||||
const isBackoffStep =
|
|
||||||
backoffStep?.type === 'drive' &&
|
|
||||||
Number(backoffStep?.speeds?.left) === -300 &&
|
|
||||||
Number(backoffStep?.speeds?.right) === -300;
|
|
||||||
const isPauseStep = pauseStep?.type === 'pause' && Number(pauseStep?.duration) === 600;
|
|
||||||
const isStopStep =
|
|
||||||
stopStep?.type === 'drive' &&
|
|
||||||
Number(stopStep?.speeds?.left) === 0 &&
|
|
||||||
Number(stopStep?.speeds?.right) === 0;
|
|
||||||
if (!isBackoffStep || !isPauseStep || !isStopStep) return steps;
|
|
||||||
return steps.slice(0, -3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ControlSystemProvider({ children }) {
|
export function ControlSystemProvider({ children }) {
|
||||||
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
const [state, dispatch] = useReducer(controlReducer, initialControlState);
|
||||||
const snapshotRef = useRef(null);
|
const snapshotRef = useRef(null);
|
||||||
@@ -158,7 +140,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const {
|
const {
|
||||||
value: controlSettings,
|
value: controlSettings,
|
||||||
save: saveControlSettings,
|
save: saveControlSettings,
|
||||||
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP, macros: DEFAULT_MACROS });
|
} = useSettingsNamespace('controls', { keymap: DEFAULT_KEYMAP });
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', { driveMacroBackoffEnabled: true });
|
const { value: pageSettings } = useSettingsNamespace('page', { driveMacroBackoffEnabled: true });
|
||||||
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
|
const { value: hornSettings } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
|
||||||
const driveMacroBackoffEnabled =
|
const driveMacroBackoffEnabled =
|
||||||
@@ -166,6 +148,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
? pageSettings.driveMacroBackoffEnabled
|
? pageSettings.driveMacroBackoffEnabled
|
||||||
: true;
|
: true;
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
|
const cameraLocked = Boolean(dockTelemetry.cameraLocked);
|
||||||
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
|
||||||
const roomLightsLocked = useSessionSelector((state) =>
|
const roomLightsLocked = useSessionSelector((state) =>
|
||||||
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
|
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
|
||||||
@@ -238,10 +222,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
|
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
|
||||||
dispatch({ type: 'control/set-keymap', payload: mergedKeymap });
|
dispatch({ type: 'control/set-keymap', payload: mergedKeymap });
|
||||||
if (controlSettings?.macros) {
|
}, [controlSettings?.keymap]);
|
||||||
dispatch({ type: 'control/set-macros', payload: controlSettings.macros });
|
|
||||||
}
|
|
||||||
}, [controlSettings?.keymap, controlSettings?.macros]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const config = pipeline.servoConfig;
|
const config = pipeline.servoConfig;
|
||||||
@@ -268,11 +249,10 @@ export function ControlSystemProvider({ children }) {
|
|||||||
typeof state.camera.angle === 'number' ? state.camera.angle : servoAngleRef.current;
|
typeof state.camera.angle === 'number' ? state.camera.angle : servoAngleRef.current;
|
||||||
}, [state.camera.angle]);
|
}, [state.camera.angle]);
|
||||||
|
|
||||||
|
const { enableSensorStream } = pipeline;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pipeline.roverId) {
|
if (roverId) enableSensorStream();
|
||||||
pipeline.enableSensorStream();
|
}, [roverId, enableSensorStream]);
|
||||||
}
|
|
||||||
}, [pipeline.roverId, pipeline.enableSensorStream]);
|
|
||||||
|
|
||||||
const setMode = useCallback(
|
const setMode = useCallback(
|
||||||
(mode) => {
|
(mode) => {
|
||||||
@@ -295,21 +275,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
const setDriveVector = useCallback(
|
const setDriveVector = useCallback(
|
||||||
(vector, meta = {}) => {
|
(vector, meta = {}) => {
|
||||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||||
if (state.manualDockAssist?.active) {
|
|
||||||
const capped = Math.max(1, Math.min(MANUAL_DOCK_ASSIST_MAX_SPEED, 500));
|
|
||||||
speedOptions.maxSpeed = Math.min(
|
|
||||||
typeof speedOptions.maxSpeed === 'number' ? speedOptions.maxSpeed : capped,
|
|
||||||
capped,
|
|
||||||
);
|
|
||||||
speedOptions.baseSpeed = Math.min(
|
|
||||||
typeof speedOptions.baseSpeed === 'number' ? speedOptions.baseSpeed : capped,
|
|
||||||
capped,
|
|
||||||
);
|
|
||||||
speedOptions.boostSpeed = Math.min(
|
|
||||||
typeof speedOptions.boostSpeed === 'number' ? speedOptions.boostSpeed : capped,
|
|
||||||
capped,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const computed = computeDifferentialSpeeds(vector, speedOptions);
|
const computed = computeDifferentialSpeeds(vector, speedOptions);
|
||||||
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
|
const isMoving = computed.speeds.left !== 0 || computed.speeds.right !== 0;
|
||||||
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
|
if (pipeline.roverId && isMoving && driveEngagementRoverRef.current !== pipeline.roverId) {
|
||||||
@@ -327,7 +292,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
if (ptzControls.applyDriveVector(vector, meta)) return;
|
if (ptzControls.applyDriveVector(vector, meta)) return;
|
||||||
pipeline.sendDriveDirect(computed.speeds);
|
pipeline.sendDriveDirect(computed.speeds);
|
||||||
},
|
},
|
||||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
[pipeline, ptzControls, recordControlIntent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAuxMotors = useCallback(
|
const setAuxMotors = useCallback(
|
||||||
@@ -369,23 +334,22 @@ export function ControlSystemProvider({ children }) {
|
|||||||
}, [saveControlSettings]);
|
}, [saveControlSettings]);
|
||||||
|
|
||||||
const setServoAngle = useCallback(
|
const setServoAngle = useCallback(
|
||||||
(value, options = {}) => {
|
(value) => {
|
||||||
/*
|
/*
|
||||||
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
||||||
enters through setCameraAxisIntent as a signed held velocity, so this
|
enters through setCameraAxisIntent as a signed held velocity, so this
|
||||||
function must not infer zoom direction by comparing unrelated absolute
|
function must not infer zoom direction by comparing unrelated absolute
|
||||||
angle values from gamepad/manual-dock callers.
|
angle values from camera controls.
|
||||||
*/
|
*/
|
||||||
if (!pipeline.servoConfig) return;
|
if (!pipeline.servoConfig) return;
|
||||||
const force = Boolean(options?.force);
|
if (cameraLocked) return;
|
||||||
if (state.manualDockAssist?.active && !force) return;
|
|
||||||
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
const clamped = clampServoAngle(pipeline.servoConfig, value);
|
||||||
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
||||||
pipeline.sendServoAngle(clamped);
|
pipeline.sendServoAngle(clamped);
|
||||||
servoAngleRef.current = clamped;
|
servoAngleRef.current = clamped;
|
||||||
recordControlIntent();
|
recordControlIntent();
|
||||||
},
|
},
|
||||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
[pipeline, recordControlIntent, cameraLocked],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nudgeServo = useCallback(
|
const nudgeServo = useCallback(
|
||||||
@@ -457,35 +421,19 @@ export function ControlSystemProvider({ children }) {
|
|||||||
dispatch({ type: 'control/set-camera-precision-mode', payload: Boolean(active) });
|
dispatch({ type: 'control/set-camera-precision-mode', payload: Boolean(active) });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runMacro = useCallback(
|
const sendCommand = useCallback((type, data = {}) => {
|
||||||
async (macroId) => {
|
recordControlIntent();
|
||||||
const macro = state.macros.find((item) => item.id === macroId) || null;
|
pipeline.emitCommand({ type, data }, (result) => {
|
||||||
if (!macro) return;
|
if (result?.error) alert(result.error);
|
||||||
let macroToRun = macro;
|
});
|
||||||
if (macroId === 'drive-sequence') {
|
}, [pipeline, recordControlIntent]);
|
||||||
turnOnAllLights();
|
|
||||||
if (!homeAssistantEntities?.length) {
|
const undock = useCallback(() => {
|
||||||
pendingLightsRef.current = true;
|
dispatch({ type: 'control/set-mode', payload: 'drive' });
|
||||||
}
|
turnOnAllLights();
|
||||||
recordControlIntent();
|
if (!homeAssistantEntities?.length) pendingLightsRef.current = true;
|
||||||
if (!driveMacroBackoffEnabled) {
|
sendCommand('undock', { backoff: driveMacroBackoffEnabled });
|
||||||
macroToRun = {
|
}, [driveMacroBackoffEnabled, homeAssistantEntities, turnOnAllLights, sendCommand]);
|
||||||
...macro,
|
|
||||||
steps: removeDriveSequenceBackoff(macro.steps),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await pipeline.runMacroSteps(macroToRun);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
driveMacroBackoffEnabled,
|
|
||||||
pipeline,
|
|
||||||
recordControlIntent,
|
|
||||||
homeAssistantEntities,
|
|
||||||
state.macros,
|
|
||||||
turnOnAllLights,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stopAllMotion = useCallback(() => {
|
const stopAllMotion = useCallback(() => {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -691,32 +639,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [dispatch, hornNeedsTick]);
|
}, [dispatch, hornNeedsTick]);
|
||||||
|
|
||||||
const setManualDockAssistActive = useCallback(
|
|
||||||
(active) => {
|
|
||||||
const nextActive = Boolean(active);
|
|
||||||
const prevActive = Boolean(state.manualDockAssist?.active);
|
|
||||||
if (prevActive === nextActive) return;
|
|
||||||
dispatch({ type: 'control/set-manual-dock-assist', payload: nextActive });
|
|
||||||
if (!nextActive) {
|
|
||||||
pipeline.sendSong([{ note: 83, duration: 10 }, { note: 76, duration: 10 }], { slot: 0 });
|
|
||||||
setServoAngle(0, { force: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const minAngle =
|
|
||||||
typeof pipeline.servoConfig?.minAngle === 'number'
|
|
||||||
? pipeline.servoConfig.minAngle
|
|
||||||
: -45;
|
|
||||||
setServoAngle(minAngle, { force: true });
|
|
||||||
pipeline.sendSong([{ note: 76, duration: 10 }, { note: 83, duration: 10 }], { slot: 0 });
|
|
||||||
recordControlIntent();
|
|
||||||
},
|
|
||||||
[pipeline, recordControlIntent, setServoAngle, state.manualDockAssist?.active],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleManualDockAssist = useCallback(() => {
|
|
||||||
setManualDockAssistActive(!state.manualDockAssist?.active);
|
|
||||||
}, [setManualDockAssistActive, state.manualDockAssist?.active]);
|
|
||||||
|
|
||||||
const registerInputState = useCallback((source, data) => {
|
const registerInputState = useCallback((source, data) => {
|
||||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -758,7 +680,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
goServoHome,
|
goServoHome,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
runMacro,
|
undock,
|
||||||
|
sendCommand,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
sendOiCommand,
|
sendOiCommand,
|
||||||
setSensorStream,
|
setSensorStream,
|
||||||
@@ -769,8 +692,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
setManualDockAssistActive,
|
|
||||||
toggleManualDockAssist,
|
|
||||||
setSongNote,
|
setSongNote,
|
||||||
sendSong,
|
sendSong,
|
||||||
startHorn,
|
startHorn,
|
||||||
@@ -787,7 +708,8 @@ export function ControlSystemProvider({ children }) {
|
|||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
goServoHome,
|
goServoHome,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
runMacro,
|
undock,
|
||||||
|
sendCommand,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
sendOiCommand,
|
sendOiCommand,
|
||||||
setSensorStream,
|
setSensorStream,
|
||||||
@@ -798,8 +720,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
updateKeyBinding,
|
updateKeyBinding,
|
||||||
resetKeyBindings,
|
resetKeyBindings,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
setManualDockAssistActive,
|
|
||||||
toggleManualDockAssist,
|
|
||||||
setSongNote,
|
setSongNote,
|
||||||
sendSong,
|
sendSong,
|
||||||
startHorn,
|
startHorn,
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import { useSocket } from '../context/SocketContext.jsx';
|
|||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import {
|
import {
|
||||||
AUX_LIMITS,
|
AUX_LIMITS,
|
||||||
COMMAND_DELAY_MS,
|
|
||||||
OI_COMMANDS,
|
OI_COMMANDS,
|
||||||
SONG_DEFAULT_DURATION,
|
SONG_DEFAULT_DURATION,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
SONG_NOTE_RANGE,
|
SONG_NOTE_RANGE,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { bytesToBase64, clampRange, sleep } from './controlMath.js';
|
import { bytesToBase64, clampRange } from './controlMath.js';
|
||||||
|
|
||||||
export function useCommandPipeline(options = {}) {
|
export function useCommandPipeline(options = {}) {
|
||||||
const { driveTransform, auxTransform } = options;
|
const { driveTransform, auxTransform } = options;
|
||||||
@@ -53,8 +52,17 @@ export function useCommandPipeline(options = {}) {
|
|||||||
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
||||||
const emitCommand = useCallback(
|
const emitCommand = useCallback(
|
||||||
(payload, cb) => {
|
(payload, cb) => {
|
||||||
if (!roverId) return;
|
if (!roverId || !socket.connected) {
|
||||||
socket.emit('command', { roverId, ...payload }, cb);
|
cb?.({ error: 'Rover control connection unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cb) {
|
||||||
|
socket.timeout(5000).emit('command', { roverId, ...payload }, (error, result) => {
|
||||||
|
cb(error ? { error: 'Rover command acknowledgement timed out' } : result);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.emit('command', { roverId, ...payload });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[socket, roverId],
|
[socket, roverId],
|
||||||
);
|
);
|
||||||
@@ -142,41 +150,6 @@ export function useCommandPipeline(options = {}) {
|
|||||||
[emitCommand, enableSensorStream, roverId],
|
[emitCommand, enableSensorStream, roverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runMacroSteps = useCallback(
|
|
||||||
async (macro) => {
|
|
||||||
if (!macro || !Array.isArray(macro.steps) || !roverId) return;
|
|
||||||
for (const step of macro.steps) {
|
|
||||||
if (!roverId) break;
|
|
||||||
switch (step.type) {
|
|
||||||
case 'oi':
|
|
||||||
sendOiCommand(step.command);
|
|
||||||
break;
|
|
||||||
case 'drive':
|
|
||||||
sendDriveDirect(step.speeds ?? { left: 0, right: 0 });
|
|
||||||
break;
|
|
||||||
case 'motors':
|
|
||||||
sendAuxMotors(step.values ?? {});
|
|
||||||
break;
|
|
||||||
case 'servo':
|
|
||||||
sendServoAngle(step.angle);
|
|
||||||
break;
|
|
||||||
case 'pause':
|
|
||||||
await sleep(step.duration ?? COMMAND_DELAY_MS); // eslint-disable-line no-await-in-loop
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (step.delay || step.delayMs) {
|
|
||||||
const delay = step.delayMs ?? step.delay;
|
|
||||||
if (typeof delay === 'number' && delay > 0) {
|
|
||||||
await sleep(delay); // eslint-disable-line no-await-in-loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[roverId, sendOiCommand, sendDriveDirect, sendAuxMotors, sendServoAngle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendHeadlight = useCallback(
|
const sendHeadlight = useCallback(
|
||||||
(action = 'toggle') => {
|
(action = 'toggle') => {
|
||||||
if (!roverId || !headlight) return null;
|
if (!roverId || !headlight) return null;
|
||||||
@@ -277,7 +250,6 @@ export function useCommandPipeline(options = {}) {
|
|||||||
sendHorn,
|
sendHorn,
|
||||||
sendPeripheralControl,
|
sendPeripheralControl,
|
||||||
sendSong,
|
sendSong,
|
||||||
runMacroSteps,
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
roverId,
|
roverId,
|
||||||
@@ -300,7 +272,6 @@ export function useCommandPipeline(options = {}) {
|
|||||||
sendHorn,
|
sendHorn,
|
||||||
sendPeripheralControl,
|
sendPeripheralControl,
|
||||||
sendSong,
|
sendSong,
|
||||||
runMacroSteps,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ export const DRIVE_LIMITS = {
|
|||||||
boostSpeed: 400,
|
boostSpeed: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MANUAL_DOCK_ASSIST_MAX_SPEED = 60;
|
|
||||||
|
|
||||||
export const COMMAND_DELAY_MS = 100;
|
|
||||||
|
|
||||||
export const SONG_NOTE_RANGE = [31, 127];
|
export const SONG_NOTE_RANGE = [31, 127];
|
||||||
export const SONG_DEFAULT_NOTE = 60;
|
export const SONG_DEFAULT_NOTE = 60;
|
||||||
export const SONG_DEFAULT_DURATION = 8;
|
export const SONG_DEFAULT_DURATION = 8;
|
||||||
@@ -64,23 +60,3 @@ export const DEFAULT_KEYMAP = {
|
|||||||
homeAssistantOn: ['ArrowRight'],
|
homeAssistantOn: ['ArrowRight'],
|
||||||
homeAssistantOff: ['ArrowLeft'],
|
homeAssistantOff: ['ArrowLeft'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_MACROS = [
|
|
||||||
{
|
|
||||||
id: 'drive-sequence',
|
|
||||||
label: 'Drive',
|
|
||||||
description: 'Start, undock, and full command sequence used by the drive button.',
|
|
||||||
steps: [
|
|
||||||
{ type: 'servo', angle: 0 },
|
|
||||||
{ type: 'oi', command: 'start' },
|
|
||||||
{ type: 'pause', duration: COMMAND_DELAY_MS },
|
|
||||||
{ type: 'oi', command: 'dock' },
|
|
||||||
{ type: 'pause', duration: COMMAND_DELAY_MS },
|
|
||||||
{ type: 'oi', command: 'full' },
|
|
||||||
{ type: 'pause', duration: 300 },
|
|
||||||
{ type: 'drive', speeds: { left: -300, right: -300 } },
|
|
||||||
{ type: 'pause', duration: 600 },
|
|
||||||
{ type: 'drive', speeds: { left: 0, right: 0 } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -55,7 +55,3 @@ export function bytesToBase64(bytes) {
|
|||||||
}
|
}
|
||||||
throw new Error('No base64 encoder available');
|
throw new Error('No base64 encoder available');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Control State Reducer
|
// Control State Reducer
|
||||||
// Purpose: Implements reducer transitions for control-system runtime state. Scope: Centralizes deterministic state updates for input, mode, and dispatch events.
|
// Purpose: Implements reducer transitions for control-system runtime state. Scope: Centralizes deterministic state updates for input, mode, and dispatch events.
|
||||||
import { DEFAULT_KEYMAP, DEFAULT_MACROS, SONG_DEFAULT_NOTE } from './constants.js';
|
import { DEFAULT_KEYMAP, SONG_DEFAULT_NOTE } from './constants.js';
|
||||||
|
|
||||||
function createDriveState() {
|
function createDriveState() {
|
||||||
return {
|
return {
|
||||||
@@ -49,12 +49,6 @@ function createMicState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualDockAssistState() {
|
|
||||||
return {
|
|
||||||
active: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const initialControlState = {
|
export const initialControlState = {
|
||||||
roverId: null,
|
roverId: null,
|
||||||
mode: 'drive',
|
mode: 'drive',
|
||||||
@@ -64,9 +58,7 @@ export const initialControlState = {
|
|||||||
song: createSongState(),
|
song: createSongState(),
|
||||||
horn: createHornState(),
|
horn: createHornState(),
|
||||||
mic: createMicState(),
|
mic: createMicState(),
|
||||||
manualDockAssist: createManualDockAssistState(),
|
|
||||||
lastControlIntentAt: 0,
|
lastControlIntentAt: 0,
|
||||||
macros: DEFAULT_MACROS,
|
|
||||||
keymap: DEFAULT_KEYMAP,
|
keymap: DEFAULT_KEYMAP,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
// Generic accessory controls do not currently report state back from roverd.
|
// Generic accessory controls do not currently report state back from roverd.
|
||||||
@@ -86,7 +78,6 @@ export function controlReducer(state, action) {
|
|||||||
song: action.payload ? state.song : createSongState(),
|
song: action.payload ? state.song : createSongState(),
|
||||||
horn: action.payload ? state.horn : createHornState(),
|
horn: action.payload ? state.horn : createHornState(),
|
||||||
mic: action.payload ? state.mic : createMicState(),
|
mic: action.payload ? state.mic : createMicState(),
|
||||||
manualDockAssist: action.payload ? state.manualDockAssist : createManualDockAssistState(),
|
|
||||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||||
};
|
};
|
||||||
case 'control/set-mode':
|
case 'control/set-mode':
|
||||||
@@ -169,11 +160,6 @@ export function controlReducer(state, action) {
|
|||||||
...state,
|
...state,
|
||||||
keymap: { ...state.keymap, ...(action.payload ?? {}) },
|
keymap: { ...state.keymap, ...(action.payload ?? {}) },
|
||||||
};
|
};
|
||||||
case 'control/set-macros':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
macros: Array.isArray(action.payload) ? action.payload : state.macros,
|
|
||||||
};
|
|
||||||
case 'control/reset':
|
case 'control/reset':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -222,14 +208,6 @@ export function controlReducer(state, action) {
|
|||||||
pttActive: Boolean(action.payload),
|
pttActive: Boolean(action.payload),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
case 'control/set-manual-dock-assist':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
manualDockAssist: {
|
|
||||||
...(state.manualDockAssist || createManualDockAssistState()),
|
|
||||||
active: Boolean(action.payload),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case 'control/set-peripheral-value': {
|
case 'control/set-peripheral-value': {
|
||||||
const roverId = String(action.payload?.roverId || '');
|
const roverId = String(action.payload?.roverId || '');
|
||||||
const peripheralId = String(action.payload?.peripheralId || '');
|
const peripheralId = String(action.payload?.peripheralId || '');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
// Gamepad Input Manager
|
// Gamepad Input Manager
|
||||||
// Purpose: Converts polled gamepad state into normalized control actions/commands. Scope: Integrates bindings, deadzone math, and dispatch callbacks for driving.
|
// Purpose: Converts polled gamepad state into normalized control actions/commands. Scope: Integrates bindings, deadzone math, and dispatch callbacks for driving.
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
@@ -17,7 +19,6 @@ import {
|
|||||||
} from './gamepadBindings.js';
|
} from './gamepadBindings.js';
|
||||||
import { subscribeGamepadHub } from './gamepadHub.js';
|
import { subscribeGamepadHub } from './gamepadHub.js';
|
||||||
import { isTextEntryActive } from './inputFocusUtils.js';
|
import { isTextEntryActive } from './inputFocusUtils.js';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import {
|
import {
|
||||||
isControllerControlLocked,
|
isControllerControlLocked,
|
||||||
markControllerDisconnected,
|
markControllerDisconnected,
|
||||||
@@ -113,12 +114,12 @@ function cycleHomeAssistant(latest, targetState) {
|
|||||||
|
|
||||||
export default function GamepadInputManager() {
|
export default function GamepadInputManager() {
|
||||||
const {
|
const {
|
||||||
setMode,
|
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
runMacro,
|
undock,
|
||||||
|
sendCommand,
|
||||||
toggleHeadlight,
|
toggleHeadlight,
|
||||||
toggleLaser,
|
toggleLaser,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
@@ -131,7 +132,7 @@ export default function GamepadInputManager() {
|
|||||||
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
const cameraAngle = useControlSelector((control) => control.state.camera?.angle);
|
||||||
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
const cameraConfig = useControlSelector((control) => control.state.camera?.config);
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const dockAssist = useManualDockAssist();
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const { focusChat } = useChatActions();
|
const { focusChat } = useChatActions();
|
||||||
const { isChatFocused } = useChatFocus();
|
const { isChatFocused } = useChatFocus();
|
||||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||||
@@ -283,7 +284,7 @@ export default function GamepadInputManager() {
|
|||||||
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
role === 'lockdown' || (role === 'admin' && sessionMode !== 'lockdown'),
|
||||||
cameraAngle,
|
cameraAngle,
|
||||||
cameraConfig,
|
cameraConfig,
|
||||||
dockAssist,
|
dockTelemetry,
|
||||||
focusChat,
|
focusChat,
|
||||||
gamepadSettings,
|
gamepadSettings,
|
||||||
homeAssistant,
|
homeAssistant,
|
||||||
@@ -292,7 +293,8 @@ export default function GamepadInputManager() {
|
|||||||
pushAlert,
|
pushAlert,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
roverId,
|
roverId,
|
||||||
runMacro,
|
undock,
|
||||||
|
sendCommand,
|
||||||
saveGamepadSettings,
|
saveGamepadSettings,
|
||||||
saveVideoSettings,
|
saveVideoSettings,
|
||||||
sendSong,
|
sendSong,
|
||||||
@@ -300,8 +302,7 @@ export default function GamepadInputManager() {
|
|||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMicPttActive,
|
setMicPttActive,
|
||||||
setMode,
|
setSongNote,
|
||||||
setSongNote,
|
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
songNote,
|
songNote,
|
||||||
startHorn,
|
startHorn,
|
||||||
@@ -462,15 +463,13 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
if (outputs.buttons.driveMacro && handleButtonEdge('driveMacro', true)) {
|
||||||
latest.dockAssist.exitAssist();
|
latest.undock();
|
||||||
latest.setMode('drive');
|
|
||||||
latest.runMacro('drive-sequence');
|
|
||||||
} else if (!outputs.buttons.driveMacro) {
|
} else if (!outputs.buttons.driveMacro) {
|
||||||
handleButtonEdge('driveMacro', false);
|
handleButtonEdge('driveMacro', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
if (outputs.buttons.dockMacro && handleButtonEdge('dockMacro', true)) {
|
||||||
latest.dockAssist.toggleAssist();
|
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
|
||||||
} else if (!outputs.buttons.dockMacro) {
|
} else if (!outputs.buttons.dockMacro) {
|
||||||
handleButtonEdge('dockMacro', false);
|
handleButtonEdge('dockMacro', false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
||||||
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
// Keyboard Input Manager
|
// Keyboard Input Manager
|
||||||
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
// Purpose: Captures and translates keyboard events into normalized control intents. Scope: Owns keydown/keyup listeners and dispatch coordination for drive controls.
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
@@ -10,7 +12,6 @@ import { markKeyboardInputActive } from './controllerRuntime.js';
|
|||||||
import { isTextInputElement } from './inputFocusUtils.js';
|
import { isTextInputElement } from './inputFocusUtils.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { INPUT_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import {
|
import {
|
||||||
SONG_DEFAULT_DURATION,
|
SONG_DEFAULT_DURATION,
|
||||||
SONG_DEFAULT_NOTE,
|
SONG_DEFAULT_NOTE,
|
||||||
@@ -86,12 +87,12 @@ function shouldIgnoreEvent(event) {
|
|||||||
|
|
||||||
export default function KeyboardInputManager() {
|
export default function KeyboardInputManager() {
|
||||||
const {
|
const {
|
||||||
setMode,
|
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
setCameraAxisIntent,
|
setCameraAxisIntent,
|
||||||
runMacro,
|
undock,
|
||||||
|
sendCommand,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
@@ -107,12 +108,12 @@ export default function KeyboardInputManager() {
|
|||||||
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
|
const cameraNudgeDegrees = useControlSelector((control) => control.state.camera?.config?.nudgeDegrees);
|
||||||
const songNote = useControlSelector((control) => control.state.song?.note);
|
const songNote = useControlSelector((control) => control.state.song?.note);
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
|
||||||
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
|
||||||
const dockAssist = useManualDockAssist();
|
|
||||||
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
const { homeAssistantSetState, pushAlert } = useSessionActions();
|
||||||
const { focusChat } = useChatActions();
|
const { focusChat } = useChatActions();
|
||||||
const { isChatFocused } = useChatFocus();
|
const { isChatFocused } = useChatFocus();
|
||||||
@@ -386,7 +387,7 @@ export default function KeyboardInputManager() {
|
|||||||
// before the browser can deliver the next keyboard event after a committed render.
|
// before the browser can deliver the next keyboard event after a committed render.
|
||||||
latestRef.current = {
|
latestRef.current = {
|
||||||
actionTokens,
|
actionTokens,
|
||||||
dockAssist,
|
dockTelemetry,
|
||||||
focusChat,
|
focusChat,
|
||||||
homeAssistant,
|
homeAssistant,
|
||||||
homeAssistantSetState,
|
homeAssistantSetState,
|
||||||
@@ -398,7 +399,8 @@ export default function KeyboardInputManager() {
|
|||||||
pushAlert,
|
pushAlert,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
roverId,
|
roverId,
|
||||||
runMacro,
|
undock,
|
||||||
|
sendCommand,
|
||||||
saveVideoSettings,
|
saveVideoSettings,
|
||||||
sendSong,
|
sendSong,
|
||||||
servoRepeatMs,
|
servoRepeatMs,
|
||||||
@@ -408,8 +410,7 @@ export default function KeyboardInputManager() {
|
|||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMicPttActive,
|
setMicPttActive,
|
||||||
setMode,
|
setSongNote,
|
||||||
setSongNote,
|
|
||||||
songNote,
|
songNote,
|
||||||
startHorn,
|
startHorn,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
@@ -452,11 +453,9 @@ export default function KeyboardInputManager() {
|
|||||||
|
|
||||||
if (newlyPressed.length > 0) {
|
if (newlyPressed.length > 0) {
|
||||||
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
if (newlyPressed.some((token) => latest.keymap.driveMacro?.has(token))) {
|
||||||
latest.dockAssist.exitAssist();
|
latest.undock();
|
||||||
latest.setMode('drive');
|
|
||||||
latest.runMacro('drive-sequence');
|
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.dockMacro?.has(token))) {
|
||||||
latest.dockAssist.toggleAssist();
|
latest.sendCommand(latest.dockTelemetry.cameraLocked ? 'cancelDocking' : 'enterDocking');
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.headlightToggle?.has(token))) {
|
||||||
latest.toggleHeadlight();
|
latest.toggleHeadlight();
|
||||||
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
} else if (newlyPressed.some((token) => latest.keymap.laserToggle?.has(token))) {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
|
||||||
import { dockTelemetryEqual, selectDockTelemetry } from '../../context/telemetryViews.js';
|
|
||||||
|
|
||||||
export function useManualDockAssist(options = {}) {
|
|
||||||
const { manageLifecycle = false } = options;
|
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
|
||||||
const active = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
|
||||||
const actions = useControlActions();
|
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
|
||||||
const chargingLabel = dockTelemetry.chargingStateLabel || '';
|
|
||||||
const docked = Boolean(dockTelemetry.homeBase);
|
|
||||||
const charging = docked && chargingLabel.toLowerCase() !== 'not charging' && chargingLabel !== '';
|
|
||||||
const wasDockedRef = useRef(false);
|
|
||||||
|
|
||||||
const enterAssist = useCallback(() => {
|
|
||||||
actions.setManualDockAssistActive(true);
|
|
||||||
}, [actions]);
|
|
||||||
|
|
||||||
const exitAssist = useCallback(() => {
|
|
||||||
actions.setManualDockAssistActive(false);
|
|
||||||
}, [actions]);
|
|
||||||
|
|
||||||
const toggleAssist = useCallback(() => {
|
|
||||||
actions.toggleManualDockAssist();
|
|
||||||
}, [actions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!manageLifecycle) return;
|
|
||||||
const justDocked = docked && !wasDockedRef.current;
|
|
||||||
const justUndocked = !docked && wasDockedRef.current;
|
|
||||||
if (active && justDocked) {
|
|
||||||
// Stop on the first home-base contact so continued input cannot push the rover against the dock.
|
|
||||||
actions.stopAllMotion();
|
|
||||||
actions.sendSong([{ note: 84, duration: 6 }], { slot: 1 });
|
|
||||||
} else if (active && justUndocked) {
|
|
||||||
actions.sendSong([{ note: 72, duration: 6 }], { slot: 1 });
|
|
||||||
}
|
|
||||||
wasDockedRef.current = docked;
|
|
||||||
}, [actions, active, docked, manageLifecycle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!manageLifecycle || !active || !charging) return;
|
|
||||||
exitAssist();
|
|
||||||
}, [active, charging, exitAssist, manageLifecycle]);
|
|
||||||
|
|
||||||
const statusLabel = charging ? 'Docked and charging' : docked ? 'Docked' : 'Docking assist active';
|
|
||||||
const statusTone = charging ? 'good' : docked ? 'warn' : 'active';
|
|
||||||
const visible = active || docked;
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
active,
|
|
||||||
docked,
|
|
||||||
charging,
|
|
||||||
cameraLocked: active,
|
|
||||||
visible,
|
|
||||||
statusLabel,
|
|
||||||
statusTone,
|
|
||||||
enterAssist,
|
|
||||||
exitAssist,
|
|
||||||
toggleAssist,
|
|
||||||
}),
|
|
||||||
[active, charging, docked, enterAssist, exitAssist, statusLabel, statusTone, toggleAssist, visible],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { dockTelemetryEqual, selectDockTelemetry } from '../../../context/telemetryViews.js';
|
||||||
|
import { useTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
||||||
// Right Pane Tabs
|
// Right Pane Tabs
|
||||||
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
|
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
@@ -22,7 +24,6 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
|||||||
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from '../../../components/OverseerPreferencePanel/index.jsx';
|
||||||
import CardFrame from '../../../components/CardFrame/index.jsx';
|
import CardFrame from '../../../components/CardFrame/index.jsx';
|
||||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useManualDockAssist } from '../../../features/manualDockAssist/useManualDockAssist.js';
|
|
||||||
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
|
import { themeGapClass, themeStackClass } from '../../../themes/index.js';
|
||||||
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
import ActivitiesTab from '../tabs/shared/ActivitiesTab/index.jsx';
|
||||||
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
import VipTab from '../tabs/shared/VipTab/index.jsx';
|
||||||
@@ -62,7 +63,7 @@ function DriveDockPanel() {
|
|||||||
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
const laserState = useControlSelector((control) => control.pipeline?.laserState);
|
||||||
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
const pipelineHorn = useControlSelector((control) => control.pipeline?.horn);
|
||||||
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
const { setServoAngle, setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
|
||||||
const dockAssist = useManualDockAssist();
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
const driveDockState = useDriveDockState(roverId);
|
const driveDockState = useDriveDockState(roverId);
|
||||||
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
const hideInlineControls = driveDockState.docked && !driveDockState.driving;
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ function DriveDockPanel() {
|
|||||||
const hornLabel = <ControlHint actionId="hornHonk" />;
|
const hornLabel = <ControlHint actionId="hornHonk" />;
|
||||||
const upLabel = <ControlHint actionId="cameraUp" />;
|
const upLabel = <ControlHint actionId="cameraUp" />;
|
||||||
const downLabel = <ControlHint actionId="cameraDown" />;
|
const downLabel = <ControlHint actionId="cameraDown" />;
|
||||||
const cameraDisabled = Boolean(!roverId || dockAssist.cameraLocked);
|
const cameraDisabled = Boolean(!roverId || dockTelemetry.cameraLocked);
|
||||||
/*
|
/*
|
||||||
Precision movement mode also tightens the servo slider step. The command
|
Precision movement mode also tightens the servo slider step. The command
|
||||||
path still sends ordinary angle targets; only the UI increment changes while
|
path still sends ordinary angle targets; only the UI increment changes while
|
||||||
|
|||||||
Reference in New Issue
Block a user