mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 11:12:16 -05:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71267873a | ||
|
|
1ba4ebe5ff | ||
|
|
607d327e9c | ||
|
|
1bdca60648 | ||
|
|
ea4af2ebb3 | ||
|
|
b2bbaf2085 | ||
|
|
18e7fb8d1b | ||
|
|
ba71106544 | ||
|
|
a395656056 | ||
|
|
50c2c1f5db | ||
|
|
b59389870e | ||
|
|
cd4aee4e74 | ||
|
|
9de0bfb1e7 | ||
|
|
a157ece816 | ||
|
|
8cb7cc5bdc | ||
|
|
34936ab05c | ||
|
|
7231809140 | ||
|
|
8ca714cf1a | ||
|
|
ffd34de56c | ||
|
|
846c9dae55 |
@@ -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)
|
||||||
+304
-15
@@ -2,18 +2,34 @@
|
|||||||
|
|
||||||
## Resume here
|
## Resume here
|
||||||
|
|
||||||
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and
|
Latest repair: full learning-path audit. Fixed missing command labels between
|
||||||
admin-created permanent snapshots replace automatic permanent publications.
|
video samples with a shared causal 100ms grid; added real-data score/baseline/
|
||||||
|
training-probe diagnostics and normalized saved control profiles for retained
|
||||||
|
recordings. Continues policy 5 / lineage v6; this audit does not reset progress.
|
||||||
|
See the final audit results section for measured synthetic evidence and limits.
|
||||||
|
|
||||||
Status: policy format 2 now predicts continuous wheel speeds plus accessory events.
|
Latest scope (authorized IPI): only drive, camera tilt, headlight and the four
|
||||||
Copyable diagnostics and main-card wheel/action displays with a session threshold
|
operating-mode/dock commands (128/131/132/143). Exclude horn, song, laser and all
|
||||||
slider are implemented. Older model files remain stored but cannot run in this format.
|
brush/vacuum motor commands from both learning and execution, including raw OI
|
||||||
Recording, bounded dataset/training, permanent named models, live inference,
|
bypasses. Raw recordings stay intact. Policy format 5, training lineage v6;
|
||||||
checked autonomous command submission, and the verified VIP card are coded.
|
startup removes earlier models and lineages through v5. Earlier scope below is
|
||||||
Human commands do not stop or pause autonomy (explicit latest user requirement).
|
historical. No configuration change required.
|
||||||
Focused local checks passed; actual recording/training, live autonomous behavior,
|
|
||||||
concurrent server performance, and browser appearance remain unverified.
|
Latest change (2026-09-29 IPI): format 4 seeds known user-control command shapes
|
||||||
The standalone CPU benchmark was run successfully on the actual server.
|
before training. Generic peripherals are excluded; sensorStream is ignored without
|
||||||
|
invalidating footage. Drive includes zero-speed drive, with no separate stop type.
|
||||||
|
Drive, camera, brushes/vacuum, headlight, laser, horn, songs and operating modes
|
||||||
|
remain supported. Raw recordings and the existing checked command pipe stay intact.
|
||||||
|
|
||||||
|
Held-out data cannot expand the schema. Unknown control variants exclude nearby
|
||||||
|
windows only, with command/count/reason diagnostics and a coverage gate that blocks
|
||||||
|
promotion. Supported portions still receive evaluation. Current training lineage
|
||||||
|
is resume-v5.pt / schedule-v5.json. Startup removes obsolete format 1-3 models and
|
||||||
|
old lineages through v4; recordings and current-format models are preserved.
|
||||||
|
|
||||||
|
Verification for this increment is recorded at the end. Earlier version-specific
|
||||||
|
notes are historical. Manual inputs never pause/cancel autonomy; disabled service
|
||||||
|
remains dormant. Actual autonomous performance has not been established.
|
||||||
|
|
||||||
This document preserves the conversation's decisions across compactions and
|
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
|
||||||
@@ -30,9 +46,8 @@ The user declined the manual server checklist and explicitly asked to continue.
|
|||||||
Do not block further work or repeatedly ask them to do it. Preserve the unverified
|
Do not block further work or repeatedly ask them to do it. Preserve the unverified
|
||||||
status and have the reader reject unusable recordings automatically.
|
status and have the reader reject unusable recordings automatically.
|
||||||
|
|
||||||
Latest increment: internal card gating, dock lead-in/tail capture, live prediction
|
Latest increment: see the command-event policy handoff at the end. Earlier
|
||||||
diagnostics, ongoing retained-data sampling, and gated model publication are coded.
|
format 2 wheel-specific design notes below are historical and superseded.
|
||||||
See the final handoff section for settings and the new training lineage.
|
|
||||||
|
|
||||||
Next work: verify the integrated feature against actual recordings and the real
|
Next work: verify the integrated feature against actual recordings and the real
|
||||||
camera/rover pipeline when that environment is available; inspect the VIP card in
|
camera/rover pipeline when that environment is available; inspect the VIP card in
|
||||||
@@ -115,7 +130,7 @@ the default development path. Intended layout:
|
|||||||
rover-learning/
|
rover-learning/
|
||||||
recordings/ Rolling video/events/session metadata (implemented)
|
recordings/ Rolling video/events/session metadata (implemented)
|
||||||
models/ Permanent UUID directories: weights.pt and model.json
|
models/ Permanent UUID directories: weights.pt and model.json
|
||||||
training/ resume-v3.pt, schedule-v3.json, and current job.json
|
training/ resume-v4.pt, schedule-v4.json, and current job.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Training/model directories are created only when service and training are enabled.
|
Training/model directories are created only when service and training are enabled.
|
||||||
@@ -786,3 +801,277 @@ no new training reset or configuration migration is required.
|
|||||||
- Focused Node syntax and Python worker entry-point checks passed. Targeted card
|
- Focused Node syntax and Python worker entry-point checks passed. Targeted card
|
||||||
lint is run for the UI change. End-to-end training/promotion, snapshot copying,
|
lint is run for the UI change. End-to-end training/promotion, snapshot copying,
|
||||||
active-session pinning and browser behavior remain unverified without live data.
|
active-session pinning and browser behavior remain unverified without live data.
|
||||||
|
|
||||||
|
|
||||||
|
### UI cleanup and obsolete models
|
||||||
|
|
||||||
|
User explicitly requested removal of UI narration and deletion of obsolete models.
|
||||||
|
Removed snapshot/threshold explanatory prose and the old commented manual-control
|
||||||
|
note; idle status now says Stopped. Kept operational status/errors and diagnostics.
|
||||||
|
Enabled-service startup now deletes permanent model directories whose validated
|
||||||
|
manifest explicitly identifies policy format 1, even when training is disabled.
|
||||||
|
Format 2 snapshots, Latest/Previous, recordings and current training state remain.
|
||||||
|
This supersedes earlier promises to preserve incompatible format 1 model files.
|
||||||
|
Deletion occurs on the actual server when the updated service starts; no production
|
||||||
|
files were accessed from this development machine.
|
||||||
|
|
||||||
|
|
||||||
|
### Recording maintenance race and diagnostic error details
|
||||||
|
|
||||||
|
Authorized by "fix it and fix that IPI" after a production report showed two
|
||||||
|
maintenance failures but omitted their error details. Scoped to those two defects;
|
||||||
|
evaluation/cache changes remain proposals, not part of this repair.
|
||||||
|
|
||||||
|
- listRecordings now tolerates ENOENT for directories/files removed between listing
|
||||||
|
and stat (dock-buffer pruning or atomic metadata rename). Other filesystem
|
||||||
|
errors still propagate; no broad error suppression or retries were added.
|
||||||
|
- Diagnostics reportVersion 2 includes actual redacted error messages, rejection
|
||||||
|
reasons, controller results, event detail, and worker stderr. Runtime catches
|
||||||
|
retain error name/code/syscall/path/stack, timestamp and operation. Recording
|
||||||
|
failures retain the same structured details through session finalization.
|
||||||
|
- A shared redactor removes URLs, credentials, user keys, email addresses and home
|
||||||
|
usernames while retaining filesystem operation context and stack frames. Error
|
||||||
|
categories remain supplementary, not replacements for actual failures.
|
||||||
|
- Node syntax checks passed for affected modules. No actual production recording
|
||||||
|
stream is available here; the race is fixed by inspection but cannot yet be
|
||||||
|
confirmed as the cause of the earlier production events. Previously discarded
|
||||||
|
error details cannot be reconstructed. No new tests or processes were created.
|
||||||
|
|
||||||
|
### Deliberate held-out selection
|
||||||
|
|
||||||
|
Latest IPI authorizes the scheduler correction only. Randomly shuffled eligible
|
||||||
|
sessions are now partitioned with exactly the worker's SHA256 first-eight-hex
|
||||||
|
modulo-five rule. Jobs select up to three training sessions and one held-out
|
||||||
|
session when both partitions exist; otherwise up to four training sessions.
|
||||||
|
No worker starts with held-out sessions alone. Existing split assignments, model
|
||||||
|
weights, and training progress are unchanged. Diagnostics expose timestamped
|
||||||
|
available/selected partition counts and the reason when evaluation cannot be
|
||||||
|
scheduled. Selection guarantees a held-out recording, not usable evaluation
|
||||||
|
windows: decoding/vocabulary/sample checks still apply in the worker.
|
||||||
|
Node syntax checks and diff whitespace checks passed; actual job selection on
|
||||||
|
production recordings remains unverified. No new tests or background processes.
|
||||||
|
|
||||||
|
### Active implementation: command-event policy (authorized IPI)
|
||||||
|
|
||||||
|
Supersedes format 2 continuous wheel output. The model predicts numeric command
|
||||||
|
scores and payload values; ordinary code constructs commands for the existing
|
||||||
|
shared command pipe. It does not generate JSON text. Drive, recorded key-release
|
||||||
|
stops, and accessories all use the threshold. Remove wheel-speed sensors and
|
||||||
|
previous drive-command inputs, but preserve raw recordings. Exact zero values
|
||||||
|
remain template constants so a demonstrated stop cannot regress into creeping.
|
||||||
|
|
||||||
|
Keep one continuing learner, Latest/Previous, admin snapshots, recording lifecycle,
|
||||||
|
permissions, and manual inputs that never cancel autonomy. No prescribed human
|
||||||
|
driving. Start a new format 3 lineage, reuse recordings, show older snapshots as
|
||||||
|
incompatible. No model-size increase or recording redesign in this change.
|
||||||
|
|
||||||
|
Update the upper card to show every command proposal with decoded parameters,
|
||||||
|
score, threshold marker and dispatch result, including below-threshold proposals.
|
||||||
|
Update copied diagnostics and evaluation for command events, parameter error,
|
||||||
|
missed stops and unwanted commands. Evaluate more than one held-out recording;
|
||||||
|
event validation is not proof of autonomous driving or physical rollout quality.
|
||||||
|
|
||||||
|
Progress: implemented; focused checks passed. User's unrelated webui/package.json
|
||||||
|
edit was preserved. No changes to runtime recording, disabled startup, control
|
||||||
|
permissions or manual-input override semantics.
|
||||||
|
|
||||||
|
Implementation details for resume:
|
||||||
|
- workers/policy.py VERSION=3, CNN/GRU unchanged in size; two heads for scores and
|
||||||
|
command parameters. Shared select_slots chooses one winner per command family
|
||||||
|
(peripheral id/control are separate families). No unconditional drive head.
|
||||||
|
- Numeric zeros and address fields stay exact template constants. Stop is the
|
||||||
|
normal recorded driveDirect {left:0,right:0} command. Unknown templates still
|
||||||
|
reject explicitly at the 64-shape limit. Arrays remain exact demonstrations.
|
||||||
|
- workers/dataset.py yields five arrays: images, state, event labels, parameters,
|
||||||
|
available shapes. It includes accepted human drive events, excludes ambiguous
|
||||||
|
conflicting events in the same 100ms window, and no longer requires an inferred
|
||||||
|
held wheel-speed target. Raw recorded sensors/commands are unchanged.
|
||||||
|
- Training reserves separate bounded samples for stop events, other commands and
|
||||||
|
no-command windows. Evaluation remains uniformly sampled. Scheduler selects
|
||||||
|
up to three training and two held-out recordings under the unchanged SHA split.
|
||||||
|
- Gate: complete evaluation, >=2 usable held-out sessions, >=128 windows, >=32
|
||||||
|
command events, >=8 stops, >=16 movement events; command precision/recall, stop
|
||||||
|
recall and movement recall each >=0.5; normalized numeric MSE <=0.1 where
|
||||||
|
applicable. Diagnostics expose each failing reason and per-command counts.
|
||||||
|
These are experimental screening thresholds, not a guarantee of useful driving.
|
||||||
|
- New optimizer/scheduler filenames prevent silently reusing format 2 weights or
|
||||||
|
old rejected-session bookkeeping. No config values need changing. Existing
|
||||||
|
permanent format 2 snapshots are preserved, but cannot start; attempting to
|
||||||
|
snapshot an old candidate reports that a new command-policy checkpoint is needed.
|
||||||
|
- Live inference keeps existing discrete toggle latching and common command limits.
|
||||||
|
The threshold applies to drive and stop just like the other command predictions.
|
||||||
|
No-event means send nothing: it DOES NOT implicitly stop an already moving rover.
|
||||||
|
It must learn explicit stop events; removing the copy shortcut is not proof that
|
||||||
|
learning will succeed. Other motion-related sensors still remain as observations.
|
||||||
|
- Upper card replaces separate wheel meters with generic scored proposals and
|
||||||
|
decoded parameters; shows applied threshold and post-clamp submitted parameters.
|
||||||
|
Copied diagnostics retain top eight proposals and up to 16 submissions per
|
||||||
|
sampled controller-history entry, including their command objects.
|
||||||
|
|
||||||
|
Verification: actual train.py --check-dependencies loaded Torch/NumPy/PyAV and
|
||||||
|
constructed format 3 (803,440 parameters); infer.py --help loaded successfully;
|
||||||
|
Node --check passed for six changed service modules; targeted card ESLint passed.
|
||||||
|
No new tests or throwaway programs. Live end-to-end training/inference and browser
|
||||||
|
visual verification require the actual environment and have not been performed.
|
||||||
|
|
||||||
|
Deferred explicitly: chronological commanded-motion replay/closed-loop evaluation,
|
||||||
|
prepared frame caching and larger architectures. Current evaluation uses held-out
|
||||||
|
100ms event windows with the live threshold/family selection, but cannot assess
|
||||||
|
temporal latching or accumulated motion from random reservoir samples. It does
|
||||||
|
not claim to verify actual physical command execution. After normal deployment,
|
||||||
|
let the new learner process retained recordings; copied reportVersion 3 diagnostics
|
||||||
|
provide the next evidence. Do not demand prescribed driving or a manual checklist.
|
||||||
|
|
||||||
|
### Obsolete model cleanup after format 3 transition
|
||||||
|
|
||||||
|
Latest user IPI explicitly authorizes deleting obsolete snapshots, checkpoints and
|
||||||
|
training state. This supersedes earlier format 2 preservation notes above.
|
||||||
|
Enabled-service startup, before trainer/driving/gateway startup, scans both models/
|
||||||
|
and training/checkpoints/. Identity-validated manifests with integer policy version
|
||||||
|
>=1 and < current POLICY_VERSION are removed. Current/future/unknown-format models
|
||||||
|
and recordings are preserved. Candidate/Latest/Previous references to obsolete
|
||||||
|
checkpoints are atomically removed before directory deletion; leased directories
|
||||||
|
are retired until release. Cleanup also removes legacy resume/schedule files and
|
||||||
|
temporary files for the unversioned and v1-v3 lineages. Current v4 state is kept.
|
||||||
|
Cleanup runs even when training is disabled, but not when the whole service is off.
|
||||||
|
Production deletion occurs on the next enabled-service startup after deployment.
|
||||||
|
Verification: focused Node syntax and diff whitespace checks passed. No production
|
||||||
|
storage was accessed; actual deletion awaits enabled-service startup on the server.
|
||||||
|
|
||||||
|
### User-control scope and evaluation coverage (2026-09-29)
|
||||||
|
|
||||||
|
Authorized by latest IPI after user clarified that controls include camera, brushes,
|
||||||
|
lights, laser, horn and songs but NOT generic peripherals. No new command pipe or
|
||||||
|
separate stop command. sensorStream is neither input/target nor a boundary. Actual
|
||||||
|
reset/reboot/update remain temporal boundaries because they interrupt observations.
|
||||||
|
|
||||||
|
Format 4 seeds drive and motor zero/nonzero field combinations, servo, light/laser
|
||||||
|
actions, the two four-frequency horn waveforms and horn release, OI start/safe/full/
|
||||||
|
dock bytes and default song. Seed values describe shapes, not artificial training
|
||||||
|
examples. Horn frequencies are numeric parameters; integral JSON numbers share
|
||||||
|
shapes with equivalent floating representations for drive/motors/servo/horn. Songs
|
||||||
|
remain exact demonstrated note arrays; other song/raw variants can extend the
|
||||||
|
training schema within its existing 64-slot bound. No promise to pre-enumerate all
|
||||||
|
possible tunes. Seeded slots receive negative examples even in sessions where that
|
||||||
|
control was not used, avoiding untrained random seeded outputs.
|
||||||
|
|
||||||
|
Evaluation freezes the training schema. Unsupported commands are reported with
|
||||||
|
payload, reason, occurrence count (up to 32 distinct descriptions), total count and
|
||||||
|
affected-window count. The affected observation history/target interval is excluded
|
||||||
|
without labeling the unknown command as inaction or skipping the entire recording.
|
||||||
|
Any unsupported held-out command blocks promotion; metrics describe only supported
|
||||||
|
windows. If none survive, dataset diagnostics still report the coverage problem.
|
||||||
|
Existing permission checks, threshold mechanics, recording and manual input behavior
|
||||||
|
are unchanged. Generic peripherals are also removed from execution allowlist and
|
||||||
|
rover compatibility profile. Previous policy models are automatically cleaned up;
|
||||||
|
no configuration edit is required.
|
||||||
|
|
||||||
|
Verification completed: train.py --check-dependencies loaded the production
|
||||||
|
worker and constructed format 4 with 803,440 parameters and predefined command
|
||||||
|
shapes. infer.py --help, Node syntax checks for the four changed service modules,
|
||||||
|
focused VipRoverLearningCard ESLint and diff whitespace checks passed.
|
||||||
|
No live server data/hardware/browser available locally. Actual training, driving,
|
||||||
|
and browser appearance remain unverified. No new tests or background processes.
|
||||||
|
Deploying starts a new learner using existing recordings; enabled-service startup
|
||||||
|
removes older policy models and optimizer/scheduler lineages through v4. Current
|
||||||
|
lineage is v5. No settings changes or prescribed human driving are required.
|
||||||
|
|
||||||
|
### Simplified controls, policy 5
|
||||||
|
|
||||||
|
User authorized removal of horn/song/laser/brush motors. Retained 13 seeded shapes:
|
||||||
|
four drive zero/nonzero combinations, two camera angle shapes, three headlight
|
||||||
|
actions, four raw operating-mode/dock constants. Generic peripherals remain excluded.
|
||||||
|
Removed excluded command history/targets via policy allowlist and execution via
|
||||||
|
Node allowlist; raw payloads must exactly match one of the four mode commands at
|
||||||
|
both boundaries. Controller shutdown now sends only zero-speed drive, leaving
|
||||||
|
manual brush/horn state alone. Raw recording and sensor telemetry unchanged.
|
||||||
|
Old policies are deleted on enabled startup; resume-v6.pt/schedule-v6.json begin a
|
||||||
|
fresh learner using retained recordings. Existing evaluation behavior unchanged.
|
||||||
|
Verification passed: production train.py --check-dependencies (policy 5, 13 seeded
|
||||||
|
shapes), infer.py --help, changed Node module syntax, focused card ESLint, and diff
|
||||||
|
whitespace checks. No live hardware/data available locally; actual learning and
|
||||||
|
driving quality remain unverified. No tests added or background processes left.
|
||||||
|
|
||||||
|
### Active full learning-path audit (authorized IPI)
|
||||||
|
|
||||||
|
User explicitly permits local training/inference with controlled synthetic input;
|
||||||
|
there are NO real recordings on this machine. Existing user-control simplification
|
||||||
|
edits were present at start and must be preserved. No new schema reset is planned.
|
||||||
|
|
||||||
|
Reproduced: production reader with a generated lossless 25 FPS video and 31 valid
|
||||||
|
human drive events labeled ZERO commands. Old sampler used 120ms frame intervals
|
||||||
|
with only 100ms target coverage. Fixed causal 100ms grid shared by dataset/inference
|
||||||
|
uses latest past image; no future pixels. The repaired fixture preserves all 31
|
||||||
|
commands. Sensor cutoff is strictly before decision in both paths.
|
||||||
|
|
||||||
|
Original objective experiment: 12 easy colored-frame samples, 120 steps, seed 7,
|
||||||
|
4 threads: loss 0.8206 -> 0.1996, mean true-command score 0.5106 -> 0.7268, recall
|
||||||
|
at 0.7 reached 2/3; at 80 steps recall was still zero. This disproves a wholly
|
||||||
|
broken gradient path; it does not diagnose real-data score suppression. Keep
|
||||||
|
existing loss/threshold until real-data diagnostics justify changing them.
|
||||||
|
|
||||||
|
New learning.py shares the actual objective for ongoing training and disposable
|
||||||
|
probe. Per-job before/after evaluation on <=96 unchanged training examples, full
|
||||||
|
held-out score distributions, threshold sweep, training-only frequency and no-op
|
||||||
|
baselines, decoded pre-server-limit parameter error, gradient norms and sampled
|
||||||
|
versus natural frequencies. Bounded <=20s/80-step memorization probe on <=12
|
||||||
|
training examples at first job/every 1000 steps; copy only, never published or
|
||||||
|
used for generalization gate. Continues current checkpoint/optimizer lineage.
|
||||||
|
|
||||||
|
Audit results (synthetic only):
|
||||||
|
- Round-tripped eight representative retained command payloads, including signed
|
||||||
|
drive/servo values, zero-speed drive, headlight and all four mode commands.
|
||||||
|
- Lossless 25 FPS recording with 31 command events: old sampler labeled 0; new
|
||||||
|
sampler labeled 31/31, including interleaved ignored sensorStream events.
|
||||||
|
- Full production train.py decoded five generated recordings, trained/exported
|
||||||
|
checkpoints, evaluated held-out sessions, and resumed optimizer/weights for a
|
||||||
|
second job. 120 -> 240 cumulative steps improved recall at unchanged 0.7 from
|
||||||
|
0 to 0.2553, with precision 1.0 at step 240. Mean true-event score rose 0.5346
|
||||||
|
-> 0.6303. Gate correctly remained failed. A lower 0.5 threshold had precision
|
||||||
|
0.6629/recall 0.8369 and 120 false-positive actions, so threshold was NOT lowered.
|
||||||
|
- Disposable diverse probe covered zero-speed drive, moving drive and servo;
|
||||||
|
reached recall 0.75 after 39 steps under its 20s budget. Sampling was corrected
|
||||||
|
to avoid probing only the first event category. Probe uses a copy and higher
|
||||||
|
learning rate for memorization; never adopts weights or changes main optimizer.
|
||||||
|
- Exported trained checkpoint reload, forward inference and decoding passed.
|
||||||
|
- Retained recordings had extra removed actuator fields, causing false model
|
||||||
|
incompatibility. Normalize saved and current profiles through controlProfile.
|
||||||
|
Direct Node exercise accepts removed laser/peripheral differences and still
|
||||||
|
rejects a changed retained camera configuration.
|
||||||
|
|
||||||
|
Diagnostics now report accepted vs represented command counts, request/dispatch
|
||||||
|
delay, maximum sensor/image age; sampled vs natural command frequencies; gradient
|
||||||
|
norms; same training-subset before/after scores; per-command score distributions;
|
||||||
|
threshold sweep and no-op/training-frequency baselines; pre-server-limit decoded
|
||||||
|
parameter errors; and bounded diverse memorization probe. Evaluation-step identity
|
||||||
|
separates last evaluation from current-job progress. Compact history avoids
|
||||||
|
repeating entire command/score tables for every job. Four concise numeric rows
|
||||||
|
were added to expanded details; full evidence is in Copy diagnostics.
|
||||||
|
|
||||||
|
The original loss and configured 0.7 threshold remain unchanged because experiments
|
||||||
|
did not establish that a different objective/threshold fixes real recordings.
|
||||||
|
Promotion keeps existing checks and additionally must beat the simple training-
|
||||||
|
frequency baseline F1. One-time retry of old reader-specific no-aligned-window
|
||||||
|
rejections preserves optimizer state and unrelated errors.
|
||||||
|
|
||||||
|
Limits: actual human footage and rover/RTSP transport unavailable. Isolated held-out
|
||||||
|
windows cannot measure sequential latch behavior or physical driving; decoded
|
||||||
|
parameter errors are explicitly before Node's rover limits/permission/safety
|
||||||
|
handling. Main model learned some synthetic cues but has not demonstrated useful
|
||||||
|
autonomous driving. Camera command encoding still uses whole-degree predictions.
|
||||||
|
The user must not be asked to drive prescribed routes or perform a manual checklist.
|
||||||
|
|
||||||
|
Verification: full synthetic worker jobs and checkpoint inference above passed;
|
||||||
|
focused Node syntax/card ESLint/diff checks passed before final report additions.
|
||||||
|
One experimental seeded run failed because its runpy launcher omitted worker
|
||||||
|
import path; corrected launcher succeeded. No production import failure occurred.
|
||||||
|
Synthetic inference-worker media exercise passed: eight prediction batches,
|
||||||
|
threshold update to 0.3, retained command types and discrete latching exercised.
|
||||||
|
This check used a generated checkpoint and a FIFO media adapter preserving
|
||||||
|
synthetic PTS (RTSP wall-clock override disabled in the experiment only); it does
|
||||||
|
not validate real network capture timing. Trained checkpoint forward/decode was
|
||||||
|
verified separately. Temporary media launchers needed fixes for file lifetime
|
||||||
|
and command-string escaping; these were experiment issues, not production fixes.
|
||||||
|
No repository tests added. Experiments finished and no background worker remains.
|
||||||
|
Final focused Node syntax and diff whitespace checks passed. Card lint passed.
|
||||||
|
No additional schema/optimizer reset introduced by this audit.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
import{r as g,R as a}from"./index-5EWtcenj.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
|
||||||
|
//# sourceMappingURL=Preview-CKTJqKe-.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||||
|
//# sourceMappingURL=SetupApp-DN7XBkOe.js.map
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import{R as W}from"./index-5EWtcenj.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
video,
|
||||||
|
audio {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
|
||||||
|
<div class="wistia_embed"></div>
|
||||||
|
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 300px;
|
||||||
|
min-height: 150px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
::slotted(.wistia_embed) {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
|
||||||
|
//# sourceMappingURL=react-DxdYv-AY.js.map
|
||||||
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import{_ as N,R as A}from"./index-5EWtcenj.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
|
||||||
|
|
||||||
|
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
|
||||||
|
`,i+=`${e.text}
|
||||||
|
|
||||||
|
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
|
||||||
|
//# sourceMappingURL=react-gdx3v4jU.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
// Configuration Database Migrations
|
// Configuration Database Migrations
|
||||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
// Scope: Upgrades database structure and stored configuration documents before current-schema validation.
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -84,6 +84,34 @@ const migrations = [
|
|||||||
`).run();
|
`).run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
run(db) {
|
||||||
|
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
|
||||||
|
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
|
||||||
|
// Upgrade history as well as the active revision so a settings rollback
|
||||||
|
// cannot reintroduce fields rejected by the new Automations schema.
|
||||||
|
for (const row of rows) {
|
||||||
|
const config = JSON.parse(row.config_json);
|
||||||
|
let changed = false;
|
||||||
|
for (const item of config.homeAssistantActivities?.items || []) {
|
||||||
|
const hasAction = Object.hasOwn(item, 'idleAction');
|
||||||
|
const hasValue = Object.hasOwn(item, 'idleValue');
|
||||||
|
if (!hasAction && !hasValue) continue;
|
||||||
|
item.automations ??= {};
|
||||||
|
item.automations.idle ??= {};
|
||||||
|
// Preserve any explicitly saved new-format setting when both formats
|
||||||
|
// exist, including an empty text value used to clear an entity.
|
||||||
|
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
|
||||||
|
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
|
||||||
|
delete item.idleAction;
|
||||||
|
delete item.idleValue;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) update.run(JSON.stringify(config), row.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function applySchemaMigrations(db) {
|
function applySchemaMigrations(db) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
|
|||||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
const { compareRoversForAssignment } = require('./roverRanking');
|
const { compareRoversForAssignment } = require('./roverRanking');
|
||||||
|
|
||||||
const socketRefs = new Map(); // socketId -> socket
|
const socketRefs = new Map(); // socketId -> socket
|
||||||
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function assignSocket(socket, options = {}) {
|
function assignSocket(socket, options = {}) {
|
||||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// avoid double assignment
|
// avoid double assignment
|
||||||
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
assignmentEvents,
|
assignmentEvents,
|
||||||
|
resumeAssignment: assignSocket,
|
||||||
|
releaseForOperatingMode: (socket) => {
|
||||||
|
// Include direct admin control as well as automatic assignment ownership.
|
||||||
|
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
|
||||||
|
const assigned = assignments.get(socket.id);
|
||||||
|
if (assigned) roverIds.add(assigned);
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
|
||||||
|
},
|
||||||
describeAssignment,
|
describeAssignment,
|
||||||
forceRelease,
|
forceRelease,
|
||||||
forceReleaseWithNotice,
|
forceReleaseWithNotice,
|
||||||
rerollAssignments,
|
rerollAssignments,
|
||||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||||
if (!socket || !roverId) return;
|
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
|
||||||
const previous = assignments.get(socket.id);
|
const previous = assignments.get(socket.id);
|
||||||
if (previous && previous !== roverId && releasePrevious) {
|
if (previous && previous !== roverId && releasePrevious) {
|
||||||
roverManager.releaseControl(previous, socket);
|
roverManager.releaseControl(previous, socket);
|
||||||
|
|||||||
@@ -2,20 +2,14 @@
|
|||||||
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
||||||
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const io = require('../../globals/io');
|
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
const { describeAssignment } = require('../assignmentService');
|
const { describeAssignment } = require('../assignmentService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const ptzCameraService = require('../ptzCameraService');
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
|
||||||
function resolvePtzChatTarget(socketId) {
|
|
||||||
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoverId(socketId) {
|
function resolveRoverId(socketId) {
|
||||||
const ptzTarget = resolvePtzChatTarget(socketId);
|
|
||||||
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
|
||||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
const assignment = describeAssignment(socketId);
|
const assignment = describeAssignment(socketId);
|
||||||
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
|
|||||||
|
|
||||||
function resolveRoverColor(roverId) {
|
function resolveRoverColor(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
||||||
return ptzCameraService.getPublicState()?.color || null;
|
|
||||||
}
|
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.color || null;
|
return record?.meta?.color || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRoverName(roverId) {
|
function resolveRoverName(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
||||||
return ptzCameraService.getPublicState()?.name || null;
|
|
||||||
}
|
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.name || null;
|
return record?.meta?.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPtzChatTargetId(roverId) {
|
|
||||||
/*
|
|
||||||
PTZ is intentionally treated as a virtual rover for chat identity only. It
|
|
||||||
does not live in roverManager.rovers because movement, video authorization,
|
|
||||||
and queue ownership are PTZ-service concerns, but chat needs one stable
|
|
||||||
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
|
|
||||||
code can all render the same badge without learning PTZ internals.
|
|
||||||
*/
|
|
||||||
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPublicChatTargetId(roverId, socket = null) {
|
function isPublicChatTargetId(roverId, socket = null) {
|
||||||
if (!roverId) return false;
|
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
|
||||||
/*
|
|
||||||
Normal rovers remain governed by the existing replay visibility rule, which
|
|
||||||
is also the rule chat historically used to avoid exposing closed private
|
|
||||||
rover activity. PTZ gets an explicit allow-list entry here because it is a
|
|
||||||
public chat target that deliberately pretends to be a rover, even though it
|
|
||||||
is not a roverManager record.
|
|
||||||
*/
|
|
||||||
if (isPtzChatTargetId(roverId)) return true;
|
|
||||||
return roverManager.canReplayRoverId(roverId, socket) === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateClosedRoverId(roverId) {
|
function isPrivateClosedRoverId(roverId) {
|
||||||
if (!roverId) return false;
|
return Boolean(roverId) && !isPublicChatTargetId(roverId);
|
||||||
/*
|
|
||||||
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
|
|
||||||
but it is not a private rover. Let PTZ-badged messages broadcast normally
|
|
||||||
instead of falling into the closed-private rover path for unknown ids.
|
|
||||||
*/
|
|
||||||
return !isPublicChatTargetId(roverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileImageUrl(value) {
|
function normalizeProfileImageUrl(value) {
|
||||||
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
|
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||||
|
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||||
roverId,
|
roverId,
|
||||||
roverName,
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
socketId,
|
socketId,
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
|
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||||
|
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||||
roverId,
|
roverId,
|
||||||
roverName,
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveRoverId,
|
resolveRoverId,
|
||||||
isPtzChatTargetId,
|
|
||||||
isPublicChatTargetId,
|
isPublicChatTargetId,
|
||||||
isPrivateClosedRoverId,
|
isPrivateClosedRoverId,
|
||||||
buildRoverCtxSnapshot,
|
buildRoverCtxSnapshot,
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
|||||||
|
|
||||||
function playTypingNote(roverId, note, socketId) {
|
function playTypingNote(roverId, note, socketId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
/*
|
|
||||||
PTZ borrows the roverId field for chat badges, but it has no rover command
|
|
||||||
channel. Skipping the song command here keeps PTZ chat from producing noisy
|
|
||||||
"unknown rover" command attempts while still allowing the message itself to
|
|
||||||
behave like rover chat everywhere else.
|
|
||||||
*/
|
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
|
||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'song',
|
type: 'song',
|
||||||
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeSpeak(socket, message, ttsOptions) {
|
function maybeSpeak(socket, message, ttsOptions) {
|
||||||
if (!ttsOptions || !message?.roverId) return;
|
if (!ttsOptions || !message) return;
|
||||||
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
if (message.operatingMode === 'ptz') {
|
||||||
/*
|
/*
|
||||||
PTZ has no rover websocket, but it does have a real speaker behind the
|
PTZ has no rover websocket, but it does have a real speaker behind the
|
||||||
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
||||||
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!message.roverId) return;
|
||||||
const record = roverManager.rovers.get(message.roverId);
|
const record = roverManager.rovers.get(message.roverId);
|
||||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
|
|||||||
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
|
|||||||
function handleChatBridgeOutbound(event) {
|
function handleChatBridgeOutbound(event) {
|
||||||
const payload = event?.payload;
|
const payload = event?.payload;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
/*
|
|
||||||
Outbound bridge filtering must use chat visibility, not rover replay
|
|
||||||
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
|
|
||||||
chat badge path can be reused, but that id is not a roverManager rover and
|
|
||||||
would be dropped by canReplayRoverId().
|
|
||||||
*/
|
|
||||||
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
||||||
const guildConfigs = listGuildConfigs();
|
const guildConfigs = listGuildConfigs();
|
||||||
if (!guildConfigs.length) return;
|
if (!guildConfigs.length) return;
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
|
|||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||||
}
|
}
|
||||||
/*
|
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
|
||||||
The chat payload already carries the resolved display name for rover-like
|
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
|
||||||
targets. Prefer that name so PTZ, which is intentionally pretending to be a
|
|
||||||
rover in chat, shows up as "PTZ Camera" instead of the internal id
|
|
||||||
"ptz-camera"; fall back to the id for older payloads or missing metadata.
|
|
||||||
*/
|
|
||||||
const roverTagLabel = payload.roverName || payload.roverId;
|
|
||||||
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
|
|
||||||
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
|
|||||||
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() {
|
||||||
let onlineUsers = 0;
|
let onlineUsers = 0;
|
||||||
@@ -113,6 +114,8 @@ function scheduleIdleTimer() {
|
|||||||
function refreshIdleState() {
|
function refreshIdleState() {
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
logger.info('Idle state refresh', activity);
|
logger.info('Idle state refresh', activity);
|
||||||
|
// Share the exact idle presence policy; Activity Controls must not count spectators or invent a second role filter.
|
||||||
|
activityControls.setOperatorsOnline(activity.totalActive > 0);
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
if (runtime.idleActionsCompleted) {
|
if (runtime.idleActionsCompleted) {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('.
|
|||||||
const { getConfiguredSocials } = require('../sessionService/configuration');
|
const { getConfiguredSocials } = require('../sessionService/configuration');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getTurnQueues } = require('../turnService');
|
const { getTurnQueues, getActiveDrivers } = require('../turnService');
|
||||||
|
const { buildRoverTurn } = require('../turnService/display');
|
||||||
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
||||||
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
@@ -157,22 +158,6 @@ function publicRoverIdSet(roster = []) {
|
|||||||
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
|
|
||||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
|
||||||
const next = {};
|
|
||||||
/*
|
|
||||||
RoverQueuesPanel creates fallback rows for queue ids that are not present in
|
|
||||||
the roster, so the public payload must filter queues with the exact same
|
|
||||||
privacy boundary as the roster. Otherwise a closed-private rover can leak as
|
|
||||||
an orphan queue row after a private access grant assigns someone to it.
|
|
||||||
*/
|
|
||||||
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
|
|
||||||
if (!visible.has(String(roverId))) return;
|
|
||||||
next[roverId] = info;
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterPublicUsers(users = [], publicIds) {
|
function filterPublicUsers(users = [], publicIds) {
|
||||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
const visible = publicIds instanceof Set ? publicIds : new Set();
|
||||||
/*
|
/*
|
||||||
@@ -203,6 +188,8 @@ function buildLocalInfo() {
|
|||||||
const publicIds = publicRoverIdSet(publicRoster);
|
const publicIds = publicRoverIdSet(publicRoster);
|
||||||
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
||||||
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
||||||
|
const turnQueues = getTurnQueues();
|
||||||
|
const activeDrivers = getActiveDrivers();
|
||||||
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
||||||
return {
|
return {
|
||||||
instance: {
|
instance: {
|
||||||
@@ -212,8 +199,12 @@ function buildLocalInfo() {
|
|||||||
features,
|
features,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
roster,
|
// Only public roster entries reach the builder; queue metadata follows the
|
||||||
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
|
// same visibility boundary instead of creating separate orphan queue rows.
|
||||||
|
roster: roster.map((rover) => ({
|
||||||
|
...rover,
|
||||||
|
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
|
||||||
|
})),
|
||||||
users,
|
users,
|
||||||
roomCameras,
|
roomCameras,
|
||||||
socials: features.socials ? getConfiguredSocials(config) : [],
|
socials: features.socials ? getConfiguredSocials(config) : [],
|
||||||
@@ -349,7 +340,6 @@ function normalizeRemotePayload(entry, payload) {
|
|||||||
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
||||||
},
|
},
|
||||||
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
||||||
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
|
|
||||||
users: Array.isArray(payload?.users) ? payload.users : [],
|
users: Array.isArray(payload?.users) ? payload.users : [],
|
||||||
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
||||||
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
||||||
@@ -419,7 +409,6 @@ function markOffline(entry, error) {
|
|||||||
publicUrl: previous.instance?.publicUrl || entry.url,
|
publicUrl: previous.instance?.publicUrl || entry.url,
|
||||||
},
|
},
|
||||||
roster: previous.roster || [],
|
roster: previous.roster || [],
|
||||||
turnQueues: previous.turnQueues || {},
|
|
||||||
users: previous.users || [],
|
users: previous.users || [],
|
||||||
roomCameras: previous.roomCameras || [],
|
roomCameras: previous.roomCameras || [],
|
||||||
socials: previous.socials || [],
|
socials: previous.socials || [],
|
||||||
|
|||||||
@@ -370,11 +370,6 @@ function createSnapshotEngine(deps) {
|
|||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
||||||
if (!roverId) return true;
|
if (!roverId) return true;
|
||||||
/*
|
|
||||||
This is a chat transcript filter, not a physical-rover filter. PTZ
|
|
||||||
chat intentionally carries a rover-like id so transcript consumers can
|
|
||||||
render it consistently, even though roverManager cannot replay that id.
|
|
||||||
*/
|
|
||||||
return isPublicChatTargetId(roverId);
|
return isPublicChatTargetId(roverId);
|
||||||
});
|
});
|
||||||
const chatRecent = allRecentMessages
|
const chatRecent = allRecentMessages
|
||||||
@@ -430,6 +425,8 @@ function createSnapshotEngine(deps) {
|
|||||||
type: 'chat',
|
type: 'chat',
|
||||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||||
text: entry.text || '',
|
text: entry.text || '',
|
||||||
|
operating_mode: entry.operatingMode || null,
|
||||||
|
operating_mode_name: entry.operatingModeDisplay?.name || null,
|
||||||
rover_id: roverId,
|
rover_id: roverId,
|
||||||
rover_ctx: { ...baseCtx, ...storedCtx },
|
rover_ctx: { ...baseCtx, ...storedCtx },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const operatingModeEvents = new EventEmitter();
|
||||||
|
const transitions = new WeakMap();
|
||||||
|
|
||||||
|
function getOperatingMode(socket) {
|
||||||
|
// Handshake intent reserves the mode before role-driven assignment runs.
|
||||||
|
// It grants no camera permissions; entry still checks access and readiness.
|
||||||
|
return socket?.data?.operatingMode
|
||||||
|
|| (socket?.handshake?.auth?.operatingMode === 'ptz' ? 'ptz' : 'rover');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(socket, operatingMode) {
|
||||||
|
// Resolve services at the action boundary to avoid a dependency cycle with
|
||||||
|
// assignment and camera policy, which both read operating mode.
|
||||||
|
const assignment = require('../assignmentService');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const ptz = require('../ptzCameraService');
|
||||||
|
if (operatingMode === 'rover' && getOperatingMode(socket) === 'rover') {
|
||||||
|
return { operatingMode, state: ptz.getPublicState(socket) };
|
||||||
|
}
|
||||||
|
if (operatingMode === 'ptz') {
|
||||||
|
await ptz.prepareParticipation(socket);
|
||||||
|
if (socket.disconnected) throw new Error('Session disconnected');
|
||||||
|
ptz.checkParticipationAccess(socket);
|
||||||
|
const leave = roverManager.canLeaveCurrentRover(socket);
|
||||||
|
if (!leave.ok) throw new Error(leave.message);
|
||||||
|
|
||||||
|
// Block new rover ownership before release events notify other services.
|
||||||
|
socket.data.operatingMode = 'ptz';
|
||||||
|
assignment.releaseForOperatingMode(socket);
|
||||||
|
socket.data.ptzEntered = true;
|
||||||
|
} else {
|
||||||
|
ptz.releaseTurn(socket);
|
||||||
|
socket.data.ptzEntered = false;
|
||||||
|
socket.data.operatingMode = 'rover';
|
||||||
|
assignment.resumeAssignment(socket);
|
||||||
|
}
|
||||||
|
operatingModeEvents.emit('change', { socket, operatingMode });
|
||||||
|
return { operatingMode, state: ptz.getPublicState(socket) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueTransition(socket, action) {
|
||||||
|
// Camera initialization can yield. Serialize switches and turn release per
|
||||||
|
// socket so a late entry cannot undo a subsequent exit or release request.
|
||||||
|
const previous = transitions.get(socket) || Promise.resolve();
|
||||||
|
const next = previous.catch(() => {}).then(() => {
|
||||||
|
if (socket.disconnected) throw new Error('Session disconnected');
|
||||||
|
return action();
|
||||||
|
});
|
||||||
|
transitions.set(socket, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOperatingMode(socket, operatingMode) {
|
||||||
|
if (!['rover', 'ptz'].includes(operatingMode)) {
|
||||||
|
return Promise.reject(new Error('Invalid operating mode'));
|
||||||
|
}
|
||||||
|
return enqueueTransition(socket, () => transition(socket, operatingMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPtzTurnAction(socket, action) {
|
||||||
|
return enqueueTransition(socket, () => {
|
||||||
|
const ptz = require('../ptzCameraService');
|
||||||
|
if (action === 'release') return ptz.releaseTurn(socket);
|
||||||
|
if (action !== 'request') throw new Error('Invalid PTZ turn action');
|
||||||
|
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
|
||||||
|
throw new Error('Enter PTZ before requesting a turn');
|
||||||
|
}
|
||||||
|
return ptz.claimTurn(socket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
|
||||||
@@ -396,11 +396,6 @@ async function runDecision(triggerReason) {
|
|||||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
if (!entry?.roverId) return true;
|
if (!entry?.roverId) return true;
|
||||||
/*
|
|
||||||
Chat context should preserve every public chat target, including the
|
|
||||||
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
|
|
||||||
it is owned by ptzCameraService instead of roverManager.
|
|
||||||
*/
|
|
||||||
return isPublicChatTargetId(entry.roverId);
|
return isPublicChatTargetId(entry.roverId);
|
||||||
})
|
})
|
||||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
||||||
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
|
||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
|
|||||||
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
||||||
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
let playbackProc = null;
|
let playbackProc = null;
|
||||||
let playbackSeq = 0;
|
let playbackSeq = 0;
|
||||||
|
|
||||||
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playFile(filePath, context = {}) {
|
async function playFile(filePath, context = {}, authorize = () => {}) {
|
||||||
if (!audioEnabled) throw new Error('PTZ audio disabled');
|
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
|
||||||
|
authorize();
|
||||||
const neolinkConfigPath = await ensureNeolinkConfig();
|
const neolinkConfigPath = await ensureNeolinkConfig();
|
||||||
const stat = await fsp.stat(filePath);
|
const stat = await fsp.stat(filePath);
|
||||||
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
||||||
|
|
||||||
|
// Rendering and config IO may outlive a turn or camera configuration.
|
||||||
|
if (disposed) throw new Error('PTZ audio configuration changed');
|
||||||
|
authorize();
|
||||||
stopActivePlayback('new-playback');
|
stopActivePlayback('new-playback');
|
||||||
const seq = ++playbackSeq;
|
const seq = ++playbackSeq;
|
||||||
const args = [
|
const args = [
|
||||||
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
|
|||||||
return { pid: proc.pid || null, seq };
|
return { pid: proc.pid || null, seq };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function speakText(text, ttsOptions = {}, context = {}) {
|
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
|
||||||
const rendered = await ensureTtsFile(text, ttsOptions);
|
const rendered = await ensureTtsFile(text, ttsOptions);
|
||||||
await playFile(rendered.filePath, {
|
await playFile(rendered.filePath, {
|
||||||
...context,
|
...context,
|
||||||
engine: rendered.engine,
|
engine: rendered.engine,
|
||||||
cached: rendered.cached,
|
cached: rendered.cached,
|
||||||
});
|
}, authorize);
|
||||||
return rendered;
|
return rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
|
|||||||
getState,
|
getState,
|
||||||
speakText,
|
speakText,
|
||||||
stopActivePlayback,
|
stopActivePlayback,
|
||||||
|
dispose: () => {
|
||||||
|
disposed = true;
|
||||||
|
stopActivePlayback('configuration-change');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
|
||||||
|
// by the service, so queue transitions cannot create physical rover ownership.
|
||||||
|
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
|
||||||
|
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function remove(socketId) {
|
||||||
|
state.queue = state.queue.filter((id) => id !== socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopOperator(reason) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
const previous = state.operatorSocketId;
|
||||||
|
state.operatorSocketId = null;
|
||||||
|
state.deadline = null;
|
||||||
|
state.generation += 1;
|
||||||
|
if (!previous) return;
|
||||||
|
revokeVideo(previous);
|
||||||
|
stopMotion(reason).catch(() => {});
|
||||||
|
events.emit('operator', { socketId: previous, action: 'release', reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTurn() {
|
||||||
|
// Uncontested control has no deadline. Starting a new wait gets a full turn,
|
||||||
|
// while later queue changes must not reset an already running countdown.
|
||||||
|
if (!state.operatorSocketId || !state.queue.length) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
state.deadline = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timer) return;
|
||||||
|
const duration = getTurnDurationMs();
|
||||||
|
state.deadline = Date.now() + duration;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
state.queue = state.queue.filter(eligible);
|
||||||
|
if (state.queue.length) {
|
||||||
|
// Preserve PTZ's explicit request-to-rejoin rule after a handoff.
|
||||||
|
stopOperator('turn-expired');
|
||||||
|
advance('turn-expired');
|
||||||
|
} else {
|
||||||
|
scheduleTurn();
|
||||||
|
emitChange('turn-uncontested');
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eligible(socketId) {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
return socket && !socket.disconnected && canParticipate(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(reason) {
|
||||||
|
state.queue = state.queue.filter(eligible);
|
||||||
|
while (!state.operatorSocketId && state.queue.length) {
|
||||||
|
const next = state.queue.shift();
|
||||||
|
if (!eligible(next)) continue;
|
||||||
|
state.operatorSocketId = next;
|
||||||
|
state.generation += 1;
|
||||||
|
events.emit('operator', { socketId: next, action: 'active' });
|
||||||
|
}
|
||||||
|
scheduleTurn();
|
||||||
|
emitChange(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function claim(socket) {
|
||||||
|
if (!eligible(socket.id)) throw new Error('PTZ participation required');
|
||||||
|
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
|
||||||
|
state.queue.push(socket.id);
|
||||||
|
advance('queue-join');
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(socketId, reason = 'turn-release') {
|
||||||
|
remove(socketId);
|
||||||
|
if (state.operatorSocketId === socketId) stopOperator(reason);
|
||||||
|
advance(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcile() {
|
||||||
|
state.queue = state.queue.filter(eligible);
|
||||||
|
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
|
||||||
|
advance('access-change');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(reason) {
|
||||||
|
state.queue = [];
|
||||||
|
stopOperator(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state, claim, release, reconcile, clear,
|
||||||
|
revoke: (reason) => { stopOperator(reason); advance(reason); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createParticipation };
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
|
||||||
|
function registerSocketGateway(deps) {
|
||||||
|
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
|
||||||
|
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
|
||||||
|
listPresets, gotoPreset, createPreset, removePreset, passesMode,
|
||||||
|
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
|
||||||
|
getSnapshot, removeSnapshotSubscriptions } = deps;
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
|
||||||
|
participation.revoke('admin-revoke');
|
||||||
|
cb({ ok: true });
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
|
||||||
|
catch (err) { cb({ error: err.message }); }
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Acknowledge acceptance of the newest desired state immediately. The
|
||||||
|
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||||
|
request latency so browser heartbeats cannot accumulate while waiting
|
||||||
|
for a camera SOAP response.
|
||||||
|
*/
|
||||||
|
cb(acceptMotionIntent(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, status: await getStatus(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, light: await setSpotlight(socket, payload) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, ir: await setIr(socket, payload) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, presets: await listPresets(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb(await gotoPreset(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb(await createPreset(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb(await removePreset(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
|
||||||
|
const ids = normalizeSnapshotIds(payload);
|
||||||
|
addSnapshotSubscription(socket, ids);
|
||||||
|
const lastSnapshotState = getSnapshot();
|
||||||
|
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
|
||||||
|
cb({ ok: true, subscribed: ids });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
|
||||||
|
const { payload } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
participation.release(socket.id, 'disconnect');
|
||||||
|
removeSnapshotSubscriptions(socket.id);
|
||||||
|
emitChange('disconnect');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerSocketGateway };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const labels = {
|
||||||
|
...require('../turnService/labels'),
|
||||||
|
waiting: 'Someone else has control',
|
||||||
|
active: 'You control the camera',
|
||||||
|
showName: 'Show camera name',
|
||||||
|
hideName: 'Hide camera name',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = labels;
|
||||||
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
|
|||||||
replay`, follows the camera currently controlled by that socket just as it
|
replay`, follows the camera currently controlled by that socket just as it
|
||||||
follows an assigned rover below.
|
follows an assigned rover below.
|
||||||
|
|
||||||
isOperator is deliberately stricter than PTZ access or queue membership:
|
turn.ownsControl is deliberately stricter than PTZ access or queue membership:
|
||||||
spectators and users waiting for a camera turn must not silently replay a
|
spectators and users waiting for a camera turn must not silently replay a
|
||||||
camera they are not currently operating. Keeping this rule here also makes
|
camera they are not currently operating. Keeping this rule here also makes
|
||||||
every web replay entry point share the same default instead of teaching the
|
every web replay entry point share the same default instead of teaching the
|
||||||
chat-command adapter about PTZ-specific state.
|
chat-command adapter about PTZ-specific state.
|
||||||
*/
|
*/
|
||||||
if (ptzCameraService.getPublicState(socket).isOperator) {
|
if (ptzCameraService.getPublicState(socket).turn.ownsControl) {
|
||||||
const source = ptzCameraService.getReplaySource();
|
const source = ptzCameraService.getReplaySource();
|
||||||
if (!source) return [];
|
if (!source) return [];
|
||||||
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
||||||
|
|||||||
@@ -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,15 +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 = 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 !== 2 || !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 = { controlProfile, compatible };
|
module.exports = { POLICY_VERSION, controlProfile, compatible };
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ module.exports = {
|
|||||||
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
|
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
|
||||||
driving: strictObject({
|
driving: strictObject({
|
||||||
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
|
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
|
||||||
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum predicted action probability for issuing a new control event.' }),
|
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum model score for issuing a command, including drive and stop commands.' }),
|
||||||
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
|
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
|
||||||
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
||||||
recording: strictObject({
|
recording: strictObject({
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
||||||
|
const { redact } = require('./errors');
|
||||||
|
const { POLICY_VERSION } = require('./capabilities');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
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',
|
||||||
'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)));
|
||||||
}
|
}
|
||||||
const implementationSha256 = hash.digest('hex');
|
const implementationSha256 = hash.digest('hex');
|
||||||
@@ -28,8 +30,8 @@ function report(state, controller) {
|
|||||||
const metrics = training.diagnostics || {};
|
const metrics = training.diagnostics || {};
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
return {
|
return {
|
||||||
reportVersion: 1, generatedAtUnixMs: state.updatedAt,
|
reportVersion: 3, generatedAtUnixMs: state.updatedAt,
|
||||||
software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version,
|
software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version,
|
||||||
dependencies: metrics.dependencies || null },
|
dependencies: metrics.dependencies || null },
|
||||||
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
|
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
|
||||||
settings: state.limits,
|
settings: state.limits,
|
||||||
@@ -37,23 +39,25 @@ function report(state, controller) {
|
|||||||
freeBytes: state.recording.freeBytes, pendingBytes: state.recording.pendingBytes,
|
freeBytes: state.recording.freeBytes, pendingBytes: state.recording.pendingBytes,
|
||||||
droppedPending: state.recording.droppedPending,
|
droppedPending: state.recording.droppedPending,
|
||||||
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
|
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
|
||||||
status: item.video, droppedEvents: item.droppedEvents, errorCategory: errorCategory(item.error) })) },
|
status: item.video, droppedEvents: item.droppedEvents, error: redact(item.error), failureDetails: item.failureDetails || null, stderr: redact(item.stderr), errorCategory: errorCategory(item.error) })) },
|
||||||
training: { status: training.status, steps: training.steps, examples: training.examples,
|
training: { status: training.status, steps: training.steps, examples: training.examples,
|
||||||
|
selection: training.selection || null, evaluatedSteps: training.evaluatedSteps ?? null,
|
||||||
loss: training.loss, losses: training.losses || null, publication: training.publication || null,
|
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 || [], rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
|
history: training.history || [], rejectionReasons: (training.rejectionReasons || []).map(redact), rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
|
||||||
errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
|
error: redact(training.error), errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
|
||||||
model: session ? { id: session.modelId, checkpointId: session.checkpointId, threshold: session.threshold, appliedThreshold: session.appliedThreshold } : null,
|
model: session ? { id: session.modelId, checkpointId: session.checkpointId, threshold: session.threshold, appliedThreshold: session.appliedThreshold } : null,
|
||||||
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
|
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
|
||||||
stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
||||||
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
|
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
|
||||||
wheels: submission.wheels, result: submission.result === 'issued' ? 'issued' : errorCategory(submission.result) })) })) } : null,
|
command: submission.command, result: redact(submission.result) })) })) } : null,
|
||||||
sensorFields: metrics.policy?.sensorFields || null,
|
sensorFields: metrics.policy?.sensorFields || null,
|
||||||
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
|
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
|
||||||
events: state.activity.map(({ at, message }) => ({ atUnixMs: at, message })),
|
events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })),
|
||||||
unavailable: ['browser_video_latency', 'actual_physical_command_execution', 'live_training_cpu_utilization'],
|
unavailable: ['browser_video_latency', 'actual_physical_command_execution', 'live_training_cpu_utilization'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const { recordCommandRequest } = require('../commandService/recording');
|
|||||||
const { acquireModel } = require('./models');
|
const { 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);
|
||||||
@@ -127,21 +124,19 @@ function createDriving({ root, config, onChange, activity }) {
|
|||||||
session.latencyMs = message.latencyMs;
|
session.latencyMs = message.latencyMs;
|
||||||
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
||||||
session.appliedThreshold = message.threshold;
|
session.appliedThreshold = message.threshold;
|
||||||
session.wheelSpeeds = message.wheelSpeeds;
|
|
||||||
session.sensorPresent = message.sensorPresent;
|
session.sensorPresent = message.sensorPresent;
|
||||||
session.submissions = [];
|
session.submissions = [];
|
||||||
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
||||||
session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels';
|
session.predictionStatus = message.commands.length ? `${message.commands.length} command(s) proposed` : 'No command proposed';
|
||||||
// Manual commands intentionally do not stop or pause this controller.
|
// Manual commands intentionally do not stop or pause this controller.
|
||||||
for (const command of message.commands) transmit(session, command);
|
for (const command of message.commands) transmit(session, command);
|
||||||
// Bound report history independently of camera and UI frame rates.
|
// Bound report history independently of camera and UI frame rates.
|
||||||
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
|
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
|
||||||
session.history.push({ at: Date.now(), wheelSpeeds: session.wheelSpeeds,
|
session.history.push({ at: Date.now(),
|
||||||
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
|
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
|
||||||
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
|
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
|
||||||
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason }) => ({ slot, score, reason })),
|
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason, command }) => ({ slot, score, reason, command })),
|
||||||
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
|
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, command })) });
|
||||||
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
|
|
||||||
if (session.history.length > 60) session.history.shift();
|
if (session.history.length > 60) session.history.shift();
|
||||||
}
|
}
|
||||||
} catch (error) { stop(roverId, error.message); break; }
|
} catch (error) { stop(roverId, error.message); break; }
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Keep actionable failures while removing credentials and user identifiers.
|
||||||
|
function redact(value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
return String(value).slice(0, 12000)
|
||||||
|
.replace(/\b(?:https?|rtsp|rtsps):\/\/[^\s'"<>]+/gi, '[redacted URL]')
|
||||||
|
.replace(/\b(Bearer\s+)\S+/gi, '$1[redacted]')
|
||||||
|
.replace(/((?:password|token|secret|authorization|cookie|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
|
||||||
|
.replace(/\bcu_[a-z0-9]+/gi, '[redacted user]')
|
||||||
|
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[redacted email]')
|
||||||
|
.replace(/\/(?:home|Users)\/[^/\s]+/g, '/[user]');
|
||||||
|
}
|
||||||
|
function errorDetails(error, operation) {
|
||||||
|
return { atUnixMs: Date.now(), operation: redact(operation),
|
||||||
|
name: redact(error?.name), message: redact(error?.message ?? error),
|
||||||
|
code: redact(error?.code), syscall: redact(error?.syscall),
|
||||||
|
path: redact(error?.path), stack: redact(error?.stack) };
|
||||||
|
}
|
||||||
|
module.exports = { redact, errorDetails };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { POLICY_VERSION } = require('./capabilities');
|
||||||
// Mutable aliases reference immutable checkpoints. All readers and retention share
|
// Mutable aliases reference immutable checkpoints. All readers and retention share
|
||||||
// a queue so a model cannot disappear between resolving its alias and loading it.
|
// a queue so a model cannot disappear between resolving its alias and loading it.
|
||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
@@ -19,7 +20,7 @@ async function index(root) {
|
|||||||
}
|
}
|
||||||
async function metadata(directory) {
|
async function metadata(directory) {
|
||||||
const model = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8'));
|
const model = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8'));
|
||||||
if (model.id !== path.basename(directory)) throw new Error('Model identity mismatch');
|
if (model?.id !== path.basename(directory)) throw new Error('Model identity mismatch');
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
async function prune(root, state) {
|
async function prune(root, state) {
|
||||||
@@ -82,6 +83,7 @@ function saveSnapshot(root) {
|
|||||||
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
|
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
|
||||||
const source = path.join(checkpointRoot(root), state.candidate);
|
const source = path.join(checkpointRoot(root), state.candidate);
|
||||||
const original = await metadata(source);
|
const original = await metadata(source);
|
||||||
|
if (original.specification?.version !== POLICY_VERSION) throw new Error('Waiting for a completed command-policy checkpoint');
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
|
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
|
||||||
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
|
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
|
||||||
@@ -98,6 +100,58 @@ function saveSnapshot(root) {
|
|||||||
return model;
|
return model;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Retire recognized older policies before workers start. Unknown/malformed or
|
||||||
|
// future manifests are not evidence that a directory is disposable.
|
||||||
|
function removeObsoleteModels(root) {
|
||||||
|
return serial(async () => {
|
||||||
|
const obsolete = [];
|
||||||
|
for (const directory of [path.join(root, 'models'), checkpointRoot(root)]) {
|
||||||
|
let entries;
|
||||||
|
try { entries = await fs.readdir(directory, { withFileTypes: true }); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !UUID.test(entry.name)) continue;
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
let model;
|
||||||
|
try { model = await metadata(target); }
|
||||||
|
catch (error) {
|
||||||
|
if (error.code === 'ENOENT' || error instanceof SyntaxError || error.message === 'Model identity mismatch') continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const version = model.specification?.version;
|
||||||
|
if (Number.isInteger(version) && version >= 1 && version < POLICY_VERSION) {
|
||||||
|
obsolete.push({ target, checkpoint: directory === checkpointRoot(root), id: entry.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state = await index(root);
|
||||||
|
const oldCheckpoints = new Set(obsolete.filter((entry) => entry.checkpoint).map((entry) => entry.id));
|
||||||
|
let changed = false;
|
||||||
|
for (const alias of ['candidate', 'latest', 'previous']) {
|
||||||
|
if (oldCheckpoints.has(state[alias])) { delete state[alias]; changed = true; }
|
||||||
|
}
|
||||||
|
// Remove aliases atomically first, so interruption cannot leave the catalog
|
||||||
|
// referring to a checkpoint that cleanup already deleted.
|
||||||
|
if (changed) {
|
||||||
|
const filename = path.join(checkpointRoot(root), 'index.json');
|
||||||
|
await fs.writeFile(`${filename}.tmp`, JSON.stringify(state));
|
||||||
|
await fs.rename(`${filename}.tmp`, filename);
|
||||||
|
}
|
||||||
|
for (const { target } of obsolete) {
|
||||||
|
if (leases.has(target)) retired.add(target);
|
||||||
|
else { await fs.rm(target, { recursive: true, force: true }); retired.delete(target); }
|
||||||
|
}
|
||||||
|
// These filenames belong to superseded optimizer/scheduler lineages, not
|
||||||
|
// policy versions. The current command policy uses lineage v6.
|
||||||
|
for (const suffix of ['', '-v1', '-v2', '-v3', '-v4', '-v5']) {
|
||||||
|
for (const filename of [`resume${suffix}.pt`, `resume${suffix}.tmp`,
|
||||||
|
`schedule${suffix}.json`, `schedule${suffix}.json.tmp`]) {
|
||||||
|
await fs.rm(path.join(root, 'training', filename), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function listModels(root) {
|
function listModels(root) {
|
||||||
return serial(async () => {
|
return serial(async () => {
|
||||||
let entries;
|
let entries;
|
||||||
@@ -116,4 +170,4 @@ function listModels(root) {
|
|||||||
return models;
|
return models;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
module.exports = { listModels, acquireModel, adoptCheckpoint, saveSnapshot };
|
module.exports = { removeObsoleteModels, listModels, acquireModel, adoptCheckpoint, saveSnapshot };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// One bounded recording session. Video packets retain receiver wall-clock PTS;
|
// One bounded recording session. Video packets retain receiver wall-clock PTS;
|
||||||
// telemetry uses the same server clock, not an assumed browser playback clock.
|
// telemetry uses the same server clock, not an assumed browser playback clock.
|
||||||
|
const { errorDetails, redact } = require('./errors');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const fsp = require('node:fs/promises');
|
const fsp = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -34,6 +35,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
let dockedAt = null;
|
let dockedAt = null;
|
||||||
let pruning = Promise.resolve();
|
let pruning = Promise.resolve();
|
||||||
let failure = null;
|
let failure = null;
|
||||||
|
let failureDetails = null;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
let stopPromise;
|
let stopPromise;
|
||||||
let child;
|
let child;
|
||||||
@@ -43,7 +45,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
let resolveVideo;
|
let resolveVideo;
|
||||||
const videoClosed = new Promise((resolve) => { resolveVideo = resolve; });
|
const videoClosed = new Promise((resolve) => { resolveVideo = resolve; });
|
||||||
output.on('error', (error) => {
|
output.on('error', (error) => {
|
||||||
failure = error.message;
|
failure = error.message; failureDetails = errorDetails(error, 'recording session');
|
||||||
// Stop file growth immediately when telemetry is no longer writable.
|
// Stop file growth immediately when telemetry is no longer writable.
|
||||||
child?.kill('SIGTERM');
|
child?.kill('SIGTERM');
|
||||||
});
|
});
|
||||||
@@ -76,7 +78,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
output.write(line);
|
output.write(line);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
metadata.droppedEvents += 1;
|
metadata.droppedEvents += 1;
|
||||||
failure = error.message;
|
failure = error.message; failureDetails = errorDetails(error, 'recording session');
|
||||||
child?.kill('SIGTERM');
|
child?.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -97,7 +99,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
'-reset_timestamps', '0', path.join(directory, 'video-%09d.mkv'),
|
'-reset_timestamps', '0', path.join(directory, 'video-%09d.mkv'),
|
||||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4096); });
|
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4096); });
|
||||||
child.on('error', (error) => { failure = error.message; });
|
child.on('error', (error) => { failure = error.message; failureDetails = errorDetails(error, 'recording session'); });
|
||||||
child.once('close', (code, signal) => {
|
child.once('close', (code, signal) => {
|
||||||
childClosed = true;
|
childClosed = true;
|
||||||
videoResult = { code, signal, stderr };
|
videoResult = { code, signal, stderr };
|
||||||
@@ -131,7 +133,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
if (!metadata.bufferOnly || stopping) return;
|
if (!metadata.bufferOnly || stopping) return;
|
||||||
if (stat.mtimeMs < Date.now() - 5000) await fsp.unlink(filename);
|
if (stat.mtimeMs < Date.now() - 5000) await fsp.unlink(filename);
|
||||||
}
|
}
|
||||||
}).catch((error) => { failure = error.message; child.kill('SIGTERM'); });
|
}).catch((error) => { failure = error.message; failureDetails = errorDetails(error, 'recording session'); child.kill('SIGTERM'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop(reason) {
|
async function stop(reason) {
|
||||||
@@ -158,9 +160,10 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
metadata.endedAt = Date.now();
|
metadata.endedAt = Date.now();
|
||||||
metadata.reason = reason;
|
metadata.reason = reason;
|
||||||
metadata.error = failure;
|
metadata.error = failure;
|
||||||
|
metadata.failureDetails = failureDetails;
|
||||||
metadata.video.result = videoResult;
|
metadata.video.result = videoResult;
|
||||||
await saveMetadata(directory, metadata);
|
await saveMetadata(directory, metadata);
|
||||||
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, stderr });
|
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, failure: failureDetails, stderr });
|
||||||
})();
|
})();
|
||||||
return stopPromise;
|
return stopPromise;
|
||||||
}
|
}
|
||||||
@@ -173,7 +176,7 @@ async function createRecording({ root, roverId, snapshot, logger, docked = false
|
|||||||
get failed() { return Boolean(failure); },
|
get failed() { return Boolean(failure); },
|
||||||
get stopping() { return stopping; },
|
get stopping() { return stopping; },
|
||||||
getStatus: () => ({ id, roverId, startedAt: metadata.startedAt, droppedEvents: metadata.droppedEvents,
|
getStatus: () => ({ id, roverId, startedAt: metadata.startedAt, droppedEvents: metadata.droppedEvents,
|
||||||
video: childClosed ? 'stopped' : metadata.bufferOnly ? 'Dock lead-in buffer' : 'Recording human driving', error: failure, stopping }),
|
video: childClosed ? 'stopped' : metadata.bufferOnly ? 'Dock lead-in buffer' : 'Recording human driving', error: redact(failure), failureDetails, stderr: redact(stderr), stopping }),
|
||||||
// Process exit is synchronous; normal disable uses stop() and awaits close.
|
// Process exit is synchronous; normal disable uses stop() and awaits close.
|
||||||
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
|
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Enabled-only recording, training, controller, and card lifecycle.
|
// Enabled-only recording, training, controller, and card lifecycle.
|
||||||
|
const { redact, errorDetails } = require('./errors');
|
||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
@@ -13,6 +14,7 @@ const { createRecording } = require('./recording');
|
|||||||
const { recoverRecordings, enforceRetention } = require('./storage');
|
const { recoverRecordings, enforceRetention } = require('./storage');
|
||||||
const { createTrainer } = require('./training');
|
const { createTrainer } = require('./training');
|
||||||
const { createDriving } = require('./driving');
|
const { createDriving } = require('./driving');
|
||||||
|
const { removeObsoleteModels } = require('./models');
|
||||||
const { createGateway } = require('./socketGateway');
|
const { createGateway } = require('./socketGateway');
|
||||||
|
|
||||||
function createRuntime({ config, logger }) {
|
function createRuntime({ config, logger }) {
|
||||||
@@ -21,7 +23,9 @@ function createRuntime({ config, logger }) {
|
|||||||
const recentActivity = [];
|
const recentActivity = [];
|
||||||
function activity(message, details = {}) {
|
function activity(message, details = {}) {
|
||||||
recentActivity.push({ at: Date.now(), message, roverId: details.roverId || null,
|
recentActivity.push({ at: Date.now(), message, roverId: details.roverId || null,
|
||||||
detail: details.reason || details.error || details.model || null });
|
detail: redact(details.reason || details.error || details.model),
|
||||||
|
error: details.failure || (details.error ? errorDetails(details.error, message) : null),
|
||||||
|
stderr: redact(details.stderr) });
|
||||||
if (recentActivity.length > 40) recentActivity.shift();
|
if (recentActivity.length > 40) recentActivity.shift();
|
||||||
}
|
}
|
||||||
const baseLogger = logger;
|
const baseLogger = logger;
|
||||||
@@ -76,7 +80,7 @@ function createRuntime({ config, logger }) {
|
|||||||
// The serialized maintenance pass still owns closing/removing the session.
|
// The serialized maintenance pass still owns closing/removing the session.
|
||||||
for (const [id, session] of sessions) {
|
for (const [id, session] of sessions) {
|
||||||
if (!next.has(id)) session.stop('no-human-controller').catch((error) => {
|
if (!next.has(id)) session.stop('no-human-controller').catch((error) => {
|
||||||
logger.warn('Unable to finalize unattended recording', { roverId: id, error: error.message });
|
logger.warn('Unable to finalize unattended recording', { roverId: id, error: error.message, failure: errorDetails(error, 'rover learning runtime') });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
schedule();
|
schedule();
|
||||||
@@ -184,7 +188,7 @@ function createRuntime({ config, logger }) {
|
|||||||
await trainer.tick(new Set([...sessions.values()].map((session) => session.id)));
|
await trainer.tick(new Set([...sessions.values()].map((session) => session.id)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Training availability must not tear down otherwise healthy capture.
|
// Training availability must not tear down otherwise healthy capture.
|
||||||
logger.error('Unable to schedule training', { error: error.message });
|
logger.error('Unable to schedule training', { error: error.message, failure: errorDetails(error, 'rover learning runtime') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +201,7 @@ function createRuntime({ config, logger }) {
|
|||||||
// The next tick is scheduled after completion: disk slowness cannot build
|
// The next tick is scheduled after completion: disk slowness cannot build
|
||||||
// an unbounded backlog of retention tasks or overlapping recorder starts.
|
// an unbounded backlog of retention tasks or overlapping recorder starts.
|
||||||
maintenance = maintain().catch(async (error) => {
|
maintenance = maintain().catch(async (error) => {
|
||||||
logger.error('Recording maintenance failed; closing recorders', { error: error.message });
|
logger.error('Recording maintenance failed; closing recorders', { error: error.message, failure: errorDetails(error, 'recording maintenance') });
|
||||||
await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'storage-error')));
|
await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'storage-error')));
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
busy = false;
|
busy = false;
|
||||||
@@ -215,12 +219,13 @@ function createRuntime({ config, logger }) {
|
|||||||
// Other server services can exit the process before asynchronous cleanup
|
// Other server services can exit the process before asynchronous cleanup
|
||||||
// finishes. Signal children immediately, and keep an exit fallback too.
|
// finishes. Signal children immediately, and keep an exit fallback too.
|
||||||
for (const session of sessions.values()) session.stop('server-shutdown').catch(() => undefined);
|
for (const session of sessions.values()) session.stop('server-shutdown').catch(() => undefined);
|
||||||
stop().catch((error) => logger.warn('Recording shutdown failed', { error: error.message }));
|
stop().catch((error) => logger.warn('Recording shutdown failed', { error: error.message, failure: errorDetails(error, 'rover learning runtime') }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
await fs.mkdir(recordingsRoot, { recursive: true });
|
await fs.mkdir(recordingsRoot, { recursive: true });
|
||||||
await recoverRecordings(recordingsRoot);
|
await recoverRecordings(recordingsRoot);
|
||||||
|
await removeObsoleteModels(root);
|
||||||
await trainer.start();
|
await trainer.start();
|
||||||
running = true;
|
running = true;
|
||||||
driving.attach();
|
driving.attach();
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ async function listRecordings(root) {
|
|||||||
if (!entry.isDirectory() || !/^\d+-[0-9a-f-]{36}$/.test(entry.name)) continue;
|
if (!entry.isDirectory() || !/^\d+-[0-9a-f-]{36}$/.test(entry.name)) continue;
|
||||||
const directory = path.join(root, entry.name);
|
const directory = path.join(root, entry.name);
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
for (const file of await fs.readdir(directory, { withFileTypes: true })) {
|
let files;
|
||||||
|
try { files = await fs.readdir(directory, { withFileTypes: true }); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
||||||
|
for (const file of files) {
|
||||||
if (!file.isFile()) continue;
|
if (!file.isFile()) continue;
|
||||||
bytes += (await fs.stat(path.join(directory, file.name))).size;
|
try { bytes += (await fs.stat(path.join(directory, file.name))).size; }
|
||||||
|
catch (error) {
|
||||||
|
// Dock-buffer pruning and atomic metadata replacement can remove a
|
||||||
|
// listed file before stat. Absence is normal; other I/O failures are not.
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
recordings.push({ id: entry.name, directory, bytes, startedAt: Number(entry.name.split('-')[0]) });
|
recordings.push({ id: entry.name, directory, bytes, startedAt: Number(entry.name.split('-')[0]) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// One owned finite worker at a time. Leases are acquired in the runtime's
|
// One owned finite worker at a time. Leases are acquired in the runtime's
|
||||||
// serialized maintenance pass, before retention can next inspect recordings.
|
// serialized maintenance pass, before retention can next inspect recordings.
|
||||||
|
const { errorDetails } = require('./errors');
|
||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
const { randomInt } = require('node:crypto');
|
const { randomInt, createHash } = require('node:crypto');
|
||||||
const { adoptCheckpoint } = require('./models');
|
const { adoptCheckpoint } = require('./models');
|
||||||
const { listRecordings } = require('./storage');
|
const { listRecordings } = require('./storage');
|
||||||
|
|
||||||
@@ -32,21 +33,28 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v3.json'), 'utf8'));
|
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v6.json'), 'utf8'));
|
||||||
ledger = saved.sessions;
|
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;
|
||||||
state = { ...state, status: 'error', error: error.message };
|
state = { ...state, status: 'error', error: error.message };
|
||||||
logger.error('Training initialization failed; recording remains available', { error: error.message });
|
logger.error('Training initialization failed; recording remains available', { error: error.message, failure: errorDetails(error, 'training initialization') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLedger() {
|
async function saveLedger() {
|
||||||
const filename = path.join(trainingRoot, 'schedule-v3.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +82,27 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
const other = randomInt(index + 1);
|
const other = randomInt(index + 1);
|
||||||
[eligible[index], eligible[other]] = [eligible[other], eligible[index]];
|
[eligible[index], eligible[other]] = [eligible[other], eligible[index]];
|
||||||
}
|
}
|
||||||
const selected = eligible.slice(0, 4);
|
// Match workers/train.py exactly. Never move a previously trained session
|
||||||
|
// into evaluation just to fill a slot: that would contaminate the holdout.
|
||||||
|
const partitions = { training: [], heldOut: [] };
|
||||||
|
for (const id of eligible) {
|
||||||
|
const heldOut = parseInt(createHash('sha256').update(id).digest('hex').slice(0, 8), 16) % 5 === 0;
|
||||||
|
partitions[heldOut ? 'heldOut' : 'training'].push(id);
|
||||||
|
}
|
||||||
|
const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4);
|
||||||
|
const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 2) : [];
|
||||||
|
const selected = [...trainingSessions, ...heldOutSessions];
|
||||||
|
const selection = {
|
||||||
|
atUnixMs: Date.now(),
|
||||||
|
availableTraining: partitions.training.length, availableHeldOut: partitions.heldOut.length,
|
||||||
|
selectedTraining: trainingSessions.length, selectedHeldOut: heldOutSessions.length,
|
||||||
|
reason: !trainingSessions.length ? 'Waiting for completed usable training-partition recordings'
|
||||||
|
: !heldOutSessions.length ? 'Training without evaluation: no eligible held-out recordings' : null,
|
||||||
|
};
|
||||||
|
state = { ...state, selection };
|
||||||
nextRun = Date.now() + config.intervalSeconds * 1000;
|
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||||
if (!selected.length) {
|
if (!selected.length) {
|
||||||
state = { ...state, status: 'waiting', reason: 'Waiting for completed usable human recordings' };
|
state = { ...state, status: 'waiting', reason: selection.reason };
|
||||||
await saveLedger();
|
await saveLedger();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,7 +110,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
await fs.writeFile(jobPath, JSON.stringify({ root, config, actionThreshold, sessions: selected }));
|
await fs.writeFile(jobPath, JSON.stringify({ root, config, actionThreshold, sessions: selected }));
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
selected.forEach((id) => leases.add(id));
|
selected.forEach((id) => leases.add(id));
|
||||||
state = { ...state, status: 'training', reason: null, sessions: selected };
|
state = { ...state, status: 'training', reason: selection.reason, sessions: selected };
|
||||||
state.startedAt = Date.now();
|
state.startedAt = Date.now();
|
||||||
child = spawn(config.python, [path.join(__dirname, 'workers/train.py'), '--job', jobPath], {
|
child = spawn(config.python, [path.join(__dirname, 'workers/train.py'), '--job', jobPath], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
@@ -141,6 +166,7 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
|
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();
|
||||||
@@ -148,10 +174,12 @@ function createTrainer({ root, config, actionThreshold, logger }) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state = { ...state, status: 'error', error: error.message };
|
state = { ...state, status: 'error', error: error.message };
|
||||||
logger.error('Training result persistence failed', { error: error.message });
|
logger.error('Training result persistence failed', { error: error.message, failure: errorDetails(error, 'training result persistence') });
|
||||||
} finally {
|
} 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,8 +8,9 @@ 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, encode_command, sensor_vector
|
from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector
|
||||||
|
|
||||||
|
|
||||||
def load_events(directory):
|
def load_events(directory):
|
||||||
@@ -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,28 +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, wheels = [], [], [(stamp, None) for stamp in boundaries]
|
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][32:] for row in sensors], axis=0).tolist()})
|
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][len(SENSORS):] for row in sensors], axis=0).tolist()})
|
||||||
for data in dispatches:
|
for data in dispatches:
|
||||||
command = data.get('command') or {}
|
command = data.get('command') or {}
|
||||||
request = requests.get(data.get('requestId'))
|
if command.get('type') in ('reset', 'reboot', 'update'):
|
||||||
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted
|
boundaries.append(data['ts'])
|
||||||
and data.get('commandId') not in failed)
|
try:
|
||||||
stamp = data['ts']
|
encoded = encode_command(command, candidate, extend=extend)
|
||||||
if command.get('type') == 'drive':
|
except ValueError as error:
|
||||||
direct = command.get('driveDirect') or {}
|
# Unrepresentable controls invalidate only nearby observations/targets,
|
||||||
pair = [direct.get('left'), direct.get('right')]
|
# not the rest of a recording. Never turn them into negative labels.
|
||||||
if human and all(isinstance(v, (int, float)) and math.isfinite(v) for v in pair):
|
request = requests.get(data.get('requestId'))
|
||||||
wheels.append((stamp, np.clip(np.array(pair, dtype=np.float32), -metadata.get('wheelSpeedLimit', 500), metadata.get('wheelSpeedLimit', 500)) / 500))
|
unsupported_times.extend([data['ts'], request['ts'] if request else data['ts']])
|
||||||
else:
|
key = json.dumps(command, sort_keys=True)
|
||||||
wheels.append((stamp, None))
|
if key not in unsupported and len(unsupported) < 32:
|
||||||
elif command.get('type') in ('raw', 'reset', 'reboot', 'update', 'sensorStream'):
|
unsupported[key] = {'command': command, 'reason': str(error), 'count': 0}
|
||||||
# Raw OI can change mode or motion; do not assume the last wheel
|
if key in unsupported:
|
||||||
# command survives it. A later explicit human drive resolves intent.
|
unsupported[key]['count'] += 1
|
||||||
wheels.append((stamp, None))
|
continue
|
||||||
boundaries.append(stamp)
|
|
||||||
encoded = encode_command(command, candidate, extend=True)
|
|
||||||
if encoded is None:
|
if encoded is None:
|
||||||
continue
|
continue
|
||||||
stamp = data['ts']
|
stamp = data['ts']
|
||||||
@@ -104,36 +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(wheels, key=lambda x: x[0]), sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
|
return metadata, sensors, sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries), sorted(unsupported_times)
|
||||||
|
|
||||||
|
|
||||||
def windows(directory, schema, cancelled=lambda: False, stats=None):
|
def windows(directory, schema, cancelled=lambda: False, stats=None, extend=True):
|
||||||
directory = Path(directory)
|
directory = Path(directory)
|
||||||
stats = stats if stats is not None else {}
|
stats = stats if stats is not None else {}
|
||||||
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats)
|
metadata, sensors, history, targets, boundaries, unsupported_times = prepare(directory, schema, stats, extend)
|
||||||
wheel_times = [row[0] for row in wheels]
|
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
|
||||||
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0,
|
'staleSensorWindows': 0, 'boundaryWindows': 0, 'maxSensorAgeMs': 0, 'maxFrameAgeMs': 0,
|
||||||
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
|
'unsupportedWindows': 0, 'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 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] = float(schema[slot]['template']['type'] != 'drive')
|
|
||||||
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:
|
||||||
@@ -141,59 +149,52 @@ 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]
|
||||||
command_values[slot] = values
|
# Do not let drive predictions simply copy earlier human/model drive commands.
|
||||||
command_times[slot] = when
|
if schema[slot]['template']['type'] != 'drive':
|
||||||
|
command_values[slot] = values
|
||||||
|
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
|
||||||
before = bisect.bisect_left(wheel_times, stamp) - 1
|
|
||||||
after = bisect.bisect_right(wheel_times, stamp + 100) - 1
|
|
||||||
if before < 0 or after < 0 or wheels[before][1] is None or wheels[after][1] is None:
|
|
||||||
stats['unknownWheelWindows'] += 1
|
|
||||||
continue
|
|
||||||
wheel_target, previous_wheels = wheels[after][1], wheels[before][1]
|
|
||||||
start = bisect.bisect_left(target_times, stamp)
|
start = bisect.bisect_left(target_times, stamp)
|
||||||
end = bisect.bisect_left(target_times, stamp + 100)
|
end = bisect.bisect_left(target_times, stamp + 100)
|
||||||
actions = np.zeros(SLOTS, dtype=np.float32)
|
actions = np.zeros(SLOTS, dtype=np.float32)
|
||||||
@@ -202,13 +203,8 @@ def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|||||||
families = set()
|
families = set()
|
||||||
for _, slot, vector in targets[start:end]:
|
for _, slot, vector in targets[start:end]:
|
||||||
template = schema[slot]['template']
|
template = schema[slot]['template']
|
||||||
family = template['type']
|
family = command_family(template)
|
||||||
if family == 'drive':
|
if ((actions[slot] and (template['type'] in ('headlight', 'raw') or not np.array_equal(values[slot], vector)))
|
||||||
continue
|
|
||||||
if family == 'peripheral':
|
|
||||||
peripheral = template.get('peripheral') or {}
|
|
||||||
family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control'))
|
|
||||||
if ((actions[slot] and (not schema[slot]['fields'] or not np.array_equal(values[slot], vector)))
|
|
||||||
or (not actions[slot] and family in families)):
|
or (not actions[slot] and family in families)):
|
||||||
ambiguous = True
|
ambiguous = True
|
||||||
break
|
break
|
||||||
@@ -217,6 +213,11 @@ 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['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1
|
stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1
|
||||||
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available, wheel_target, previous_wheels
|
stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema)))
|
||||||
|
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import time
|
|||||||
import av
|
import 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, encode_command, sensor_vector
|
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -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()
|
||||||
@@ -78,7 +72,7 @@ def main():
|
|||||||
args.threshold = max(.05, min(.99, float(event['threshold'])))
|
args.threshold = max(.05, min(.99, float(event['threshold'])))
|
||||||
elif event['kind'] == 'sensor':
|
elif event['kind'] == 'sensor':
|
||||||
sensors, sensor_time = event['data'], event['ts']
|
sensors, sensor_time = event['data'], event['ts']
|
||||||
elif event['kind'] == 'command':
|
elif event['kind'] == 'command' and event['data'].get('type') != 'drive':
|
||||||
try:
|
try:
|
||||||
encoded = encode_command(event['data'], schema)
|
encoded = encode_command(event['data'], schema)
|
||||||
if encoded:
|
if encoded:
|
||||||
@@ -94,68 +88,35 @@ 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()
|
||||||
with torch.inference_mode():
|
with torch.inference_mode():
|
||||||
logits, numeric, wheel_output = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
|
logits, numeric = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
|
||||||
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
|
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
|
||||||
probabilities = logits[0].sigmoid().tolist()
|
probabilities = logits[0].sigmoid().tolist()
|
||||||
predictions = numeric[0].tolist()
|
predictions = numeric[0].tolist()
|
||||||
wheel_speeds = [round(value * 500) for value in wheel_output[0].tolist()]
|
|
||||||
if not all(math.isfinite(value) for value in probabilities):
|
if not all(math.isfinite(value) for value in probabilities):
|
||||||
raise ValueError('Nonfinite model output')
|
raise ValueError('Nonfinite model output')
|
||||||
families = {}
|
winners = select_slots(probabilities, schema, args.threshold)
|
||||||
active = set()
|
active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold}
|
||||||
proposals = []
|
|
||||||
for slot, descriptor in enumerate(schema):
|
|
||||||
if descriptor['template']['type'] == 'drive':
|
|
||||||
continue
|
|
||||||
command = json.loads(json.dumps(descriptor['template']))
|
|
||||||
family = command['type']
|
|
||||||
if family == 'peripheral':
|
|
||||||
family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control'])
|
|
||||||
for index, field in enumerate(descriptor['fields']):
|
|
||||||
number = predictions[slot][index]
|
|
||||||
if not math.isfinite(number):
|
|
||||||
raise ValueError('Nonfinite control value')
|
|
||||||
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
|
|
||||||
target = command
|
|
||||||
for key in field['path'][:-1]:
|
|
||||||
target = target[key]
|
|
||||||
target[field['path'][-1]] = round(number) if field['integer'] else number
|
|
||||||
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command,
|
|
||||||
'reason': 'Below threshold'})
|
|
||||||
if probabilities[slot] < args.threshold:
|
|
||||||
continue
|
|
||||||
active.add(slot)
|
|
||||||
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
|
||||||
if family not in families or probabilities[slot] > families[family][0]:
|
|
||||||
families[family] = (probabilities[slot], command, slot, discrete)
|
|
||||||
latched.intersection_update(active)
|
latched.intersection_update(active)
|
||||||
# Choose the winning action before latching, so a held toggle cannot
|
proposals, commands = [], []
|
||||||
# expose a competing lower-confidence toggle on the following tick.
|
for slot, descriptor in enumerate(schema):
|
||||||
commands = [command for _, command, slot, discrete in families.values()
|
command = decode_command(descriptor, predictions[slot])
|
||||||
if not discrete or slot not in latched]
|
discrete = command['type'] in ('headlight', 'raw')
|
||||||
winners = {row[2]: row for row in families.values()}
|
reason = ('Below threshold' if slot not in active else
|
||||||
for proposal in proposals:
|
'Competing action' if slot not in winners else
|
||||||
slot = proposal['slot']
|
'Held discrete action' if discrete and slot in latched else 'Proposed')
|
||||||
if slot in active:
|
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason})
|
||||||
proposal['reason'] = ('Competing action' if slot not in winners else
|
if reason == 'Proposed':
|
||||||
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed')
|
commands.append(command)
|
||||||
for _, _, slot, discrete in families.values():
|
if slot in winners and discrete:
|
||||||
if discrete:
|
|
||||||
latched.add(slot)
|
latched.add(slot)
|
||||||
commands.insert(0, {'type': 'drive', 'driveDirect': dict(zip(('left', 'right'), wheel_speeds))})
|
print(json.dumps({'kind': 'prediction', 'frameAt': captured_at, 'decisionAt': stamp, 'sensorAt': sensor_time,
|
||||||
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
|
|
||||||
'latencyMs': (time.perf_counter() - before) * 1000,
|
'latencyMs': (time.perf_counter() - before) * 1000,
|
||||||
'wheelSpeeds': wheel_speeds, 'sensorPresent': sensor_vector(sensors)[32:].tolist(),
|
'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(),
|
||||||
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
|
'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 = 2
|
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),
|
||||||
@@ -26,7 +29,6 @@ SENSORS = [
|
|||||||
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
||||||
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
||||||
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
||||||
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
|
|
||||||
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
||||||
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
||||||
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
||||||
@@ -55,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 = [], []
|
||||||
@@ -62,16 +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 actuator zeros preserve demonstrated releases.
|
||||||
|
if value == 0:
|
||||||
|
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)
|
||||||
@@ -89,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)
|
||||||
@@ -97,6 +104,59 @@ 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):
|
||||||
|
return (command['type'],)
|
||||||
|
|
||||||
|
|
||||||
|
def select_slots(scores, schema, threshold):
|
||||||
|
winners = {}
|
||||||
|
for slot, descriptor in enumerate(schema):
|
||||||
|
if scores[slot] < threshold:
|
||||||
|
continue
|
||||||
|
family = command_family(descriptor['template'])
|
||||||
|
if family not in winners or scores[slot] > scores[winners[family]]:
|
||||||
|
winners[family] = slot
|
||||||
|
return set(winners.values())
|
||||||
|
|
||||||
|
|
||||||
|
def decode_command(descriptor, values):
|
||||||
|
command = copy.deepcopy(descriptor['template'])
|
||||||
|
for index, field in enumerate(descriptor['fields']):
|
||||||
|
number = float(values[index])
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError('Nonfinite control value')
|
||||||
|
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
|
||||||
|
target = command
|
||||||
|
for key in field['path'][:-1]:
|
||||||
|
target = target[key]
|
||||||
|
target[field['path'][-1]] = round(number) if field['integer'] else number
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def is_stop(descriptor):
|
||||||
|
command = descriptor['template']
|
||||||
|
direct = command.get('driveDirect') or {}
|
||||||
|
return command['type'] == 'drive' and direct.get('left') == 0 and direct.get('right') == 0
|
||||||
|
|
||||||
|
|
||||||
class Policy(nn.Module):
|
class Policy(nn.Module):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -109,7 +169,6 @@ class Policy(nn.Module):
|
|||||||
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
||||||
)
|
)
|
||||||
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
||||||
self.wheels = nn.Linear(128, 2)
|
|
||||||
self.actions = nn.Linear(128, SLOTS)
|
self.actions = nn.Linear(128, SLOTS)
|
||||||
self.values = nn.Linear(128, SLOTS * VALUES)
|
self.values = nn.Linear(128, SLOTS * VALUES)
|
||||||
|
|
||||||
@@ -118,17 +177,16 @@ class Policy(nn.Module):
|
|||||||
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
|
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
|
||||||
history, _ = self.history(torch.cat((encoded, state), dim=-1))
|
history, _ = self.history(torch.cat((encoded, state), dim=-1))
|
||||||
last = history[:, -1]
|
last = history[:, -1]
|
||||||
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh(), self.wheels(last).tanh()
|
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh()
|
||||||
|
|
||||||
|
|
||||||
def specification(schema):
|
def specification(schema):
|
||||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2',
|
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v5',
|
||||||
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
||||||
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
||||||
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
||||||
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
||||||
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
||||||
'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms',
|
'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state',
|
||||||
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'],
|
'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants',
|
||||||
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
|
|
||||||
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
||||||
|
|||||||
@@ -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
|
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')
|
||||||
@@ -70,13 +70,15 @@ def main():
|
|||||||
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
||||||
model = Policy().cpu()
|
model = Policy().cpu()
|
||||||
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
||||||
# A fresh training lineage prevents previously trained footage leaking into validation.
|
# The command-event policy has different inputs, targets and weights. Keep its
|
||||||
resume_path = root / 'training' / 'resume-v3.pt'
|
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
||||||
schema, steps, examples, last_published = [], 0, 0, 0
|
resume_path = root / 'training' / 'resume-v6.pt'
|
||||||
|
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:
|
||||||
@@ -90,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 = []
|
||||||
@@ -107,25 +111,24 @@ def main():
|
|||||||
candidate_schema = copy.deepcopy(schema)
|
candidate_schema = copy.deepcopy(schema)
|
||||||
samples, seen = [], 0
|
samples, seen = [], 0
|
||||||
stats = {}
|
stats = {}
|
||||||
buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')}
|
buckets = {key: [] for key in ('stop', 'command', 'none')}
|
||||||
counts = dict.fromkeys(buckets, 0)
|
counts = dict.fromkeys(buckets, 0)
|
||||||
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 ordinary turns/reversing so idle or straight motion cannot
|
# for recorded commands and releases so periods without commands
|
||||||
# consume the entire sample budget. No scripted driving required.
|
# cannot consume the entire sample budget. No scripted driving required.
|
||||||
if held_out:
|
if held_out:
|
||||||
reservoir, count, capacity = samples, seen, per_session
|
reservoir, count, capacity = samples, seen, per_session
|
||||||
else:
|
else:
|
||||||
left, right = sample[5]
|
category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
|
||||||
category = ('stopped' if max(abs(left), abs(right)) <= .02 else
|
for slot, descriptor in enumerate(candidate_schema)) else
|
||||||
'turn' if abs(left - right) > .1 else
|
'command' if sample[2].any() else 'none')
|
||||||
'reverse' if left + right < 0 else 'forward')
|
|
||||||
counts[category] += 1
|
counts[category] += 1
|
||||||
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 4)
|
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 3)
|
||||||
if len(reservoir) < capacity:
|
if len(reservoir) < capacity:
|
||||||
reservoir.append(sample)
|
reservoir.append(sample)
|
||||||
else:
|
else:
|
||||||
@@ -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
|
||||||
@@ -164,22 +167,23 @@ def main():
|
|||||||
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
||||||
rejected[session_id] = str(error)[:500]
|
rejected[session_id] = str(error)[:500]
|
||||||
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
||||||
if len(pool) < config['minimumSamples'] or not any(np.max(np.abs(sample[5])) > .02 for sample in pool) or cancelled():
|
if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled():
|
||||||
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
||||||
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
|
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
|
||||||
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
|
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
|
||||||
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,28 +192,25 @@ 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, wheel_targets, previous_wheels = [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, wheel_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')
|
loss = event_loss + value_loss
|
||||||
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
|
||||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
|
||||||
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
|
||||||
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets)
|
|
||||||
loss = wheel_loss + event_loss + value_loss
|
|
||||||
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)
|
||||||
losses.append(loss.item())
|
losses.append(loss.item())
|
||||||
if steps % 10 == 0:
|
if steps % 10 == 0:
|
||||||
emit('progress', steps=steps, examples=examples, loss=loss.item(),
|
emit('progress', steps=steps, examples=examples, loss=loss.item(),
|
||||||
losses={'wheels': wheel_loss.item(), 'events': event_loss.item(), 'accessoryValues': value_loss.item()})
|
losses={'events': event_loss.item(), 'commandValues': value_loss.item()})
|
||||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||||
break
|
break
|
||||||
if steps == initial_steps:
|
if steps == initial_steps:
|
||||||
@@ -220,65 +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 = 0
|
precision, recall = measured.get('precision', 0), measured.get('recall', 0)
|
||||||
evaluated = 0
|
actual_positive = measured.get('positiveActions', 0)
|
||||||
wheel_error = stopped_error = persistence_error = 0
|
mse = measured.get('numericMse', 0)
|
||||||
changed_error = changed_persistence = changed_count = moving_count = 0
|
stop_actual = measured.get('stopEvents', 0)
|
||||||
with torch.inference_mode():
|
stop_recall = measured.get('stopRecall', 0)
|
||||||
for offset in range(0, len(validation), config['batchSize']):
|
drive_actual = measured.get('movementEvents', 0)
|
||||||
if cancelled():
|
drive_recall = measured.get('movementRecall', 0)
|
||||||
break
|
frequency_f1 = max((row['f1'] for row in measured.get('baselines', {}).get('trainingFrequency', [])), default=0)
|
||||||
batch = validation[offset:offset + config['batchSize']]
|
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
||||||
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
gate_reasons = []
|
||||||
logits, predictions, wheel_predictions = model(images.float() / 255, states)
|
for condition, reason in [
|
||||||
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool()
|
(unsupported_events == 0, 'Held-out controls are not fully represented; see unsupportedCommands'),
|
||||||
actual = targets.bool() & available.bool()
|
(evaluated == len(validation), 'Evaluation interrupted'),
|
||||||
true_positive += (predicted & actual).sum().item()
|
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
||||||
predicted_positive += predicted.sum().item()
|
(evaluated >= 128, 'Need 128 held-out windows'),
|
||||||
actual_positive += actual.sum().item()
|
(actual_positive >= 32, 'Need 32 held-out command events'),
|
||||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
(stop_actual >= 8, 'Need eight held-out stop events'),
|
||||||
value_error += ((predictions - values).square() * mask).sum().item()
|
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
||||||
value_count += mask.sum().item()
|
(drive_recall >= .5, 'Movement command recall below 0.5'),
|
||||||
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500
|
(precision >= .5, 'Command precision below 0.5'),
|
||||||
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500
|
(recall >= .5, 'Command recall below 0.5'),
|
||||||
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02
|
(stop_recall >= .5, 'Stop recall below 0.5'),
|
||||||
changed_error += errors[changed].sum().item()
|
(measured.get('f1', 0) > frequency_f1, 'Command F1 does not beat the training-frequency baseline'),
|
||||||
changed_persistence += persistence[changed].sum().item()
|
(mse <= .1, 'Command parameter error above 0.1'),
|
||||||
changed_count += changed.sum().item()
|
]:
|
||||||
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item()
|
if not condition:
|
||||||
wheel_error += errors.sum().item()
|
gate_reasons.append(reason)
|
||||||
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500
|
evaluation = {**measured, 'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
|
||||||
persistence_error += persistence.sum().item()
|
'unsupportedCommandEvents': unsupported_events, 'unsupportedWindows': unsupported_windows,
|
||||||
evaluated += len(batch)
|
'method': 'Isolated held-out 100ms events; live selection, no temporal latch/physical rollout',
|
||||||
precision = true_positive / max(1, predicted_positive)
|
'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions,
|
||||||
recall = true_positive / max(1, actual_positive)
|
'gateReasons': gate_reasons}
|
||||||
mse = value_error / max(1, value_count)
|
after_training = evaluate(model, audit_subset, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
|
||||||
wheel_mae = wheel_error / max(1, evaluated)
|
probe_seconds = 0
|
||||||
stopped_mae = stopped_error / max(1, evaluated)
|
if not cancelled() and (learning_probe is None or steps - last_probe_steps >= 1000):
|
||||||
persistence_mae = persistence_error / max(1, evaluated)
|
remaining = config['maxJobSeconds'] - (time.monotonic() - started)
|
||||||
changed_mae = changed_error / max(1, changed_count)
|
if remaining > 10:
|
||||||
changed_baseline = changed_persistence / max(1, changed_count)
|
probe_started = time.monotonic()
|
||||||
# Check control changes separately: copying the previous speed can look
|
learning_probe = probe(model, audit_subset, schema, job['actionThreshold'], frequencies,
|
||||||
# excellent on long straight runs without learning to steer or stop.
|
cancelled, max_seconds=min(20, remaining / 2))
|
||||||
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32
|
learning_probe['atTrainingStep'] = steps
|
||||||
and changed_count >= 32 and wheel_mae < stopped_mae
|
last_probe_steps = steps
|
||||||
and wheel_mae <= persistence_mae and changed_mae < changed_baseline)
|
probe_seconds = time.monotonic() - probe_started
|
||||||
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else
|
training_audit = {'before': before_training, 'after': after_training,
|
||||||
actual_positive >= 32 and precision >= .5 and recall >= .5)
|
'gradientNormBeforeClipping': {'min': min(gradient_norms), 'max': max(gradient_norms)},
|
||||||
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1)
|
'positiveWeights': positive_weight[:len(schema)].tolist(),
|
||||||
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met',
|
'naturalTrainingCommandFrequency': frequencies[:len(schema)].tolist(),
|
||||||
'windows': evaluated, 'precision': precision, 'recall': recall,
|
'sampledCommandFrequency': np.mean([row[2] for row in pool], axis=0)[:len(schema)].tolist(),
|
||||||
'numericMse': mse, 'positiveActions': actual_positive,
|
'learningProbe': learning_probe}
|
||||||
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae,
|
evaluation_seconds = time.monotonic() - evaluation_started - probe_seconds
|
||||||
'previousSpeedBaselineMaeMmPerSecond': persistence_mae,
|
|
||||||
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline,
|
|
||||||
'changedWindows': changed_count, 'movingWindows': moving_count,
|
|
||||||
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass}
|
|
||||||
evaluation_seconds = time.monotonic() - evaluation_started
|
|
||||||
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'],
|
||||||
@@ -331,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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
// rover Manager private access
|
// rover Manager private access
|
||||||
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
|
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
|
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
|
||||||
@@ -80,6 +81,7 @@ function createPrivateAccessPolicy(deps) {
|
|||||||
allowClosedPrivateGrantInLockdown = false,
|
allowClosedPrivateGrantInLockdown = false,
|
||||||
allowClosedPrivateCurrentDriver = false,
|
allowClosedPrivateCurrentDriver = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
if (getOperatingMode(socket) !== 'rover') return 'Leave PTZ before requesting rover control';
|
||||||
if (!record) return 'Unknown rover';
|
if (!record) return 'Unknown rover';
|
||||||
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
|
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
|
||||||
if (record.locked && !isAdmin(socket)) return 'Rover locked';
|
if (record.locked && !isAdmin(socket)) return 'Rover locked';
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('sessionService');
|
const logger = require('../../globals/logger').child('sessionService');
|
||||||
const { getFeatureFlags, configurationEvents } = require('../../configuration');
|
const { getFeatureFlags, configurationEvents } = require('../../configuration');
|
||||||
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
||||||
|
const { getOperatingMode, setOperatingMode, operatingModeEvents } = require('../operatingModeService');
|
||||||
const { getMode, modeEvents } = require('../modeManager');
|
const { getMode, modeEvents } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { managerEvents } = roverManager;
|
const { managerEvents } = roverManager;
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
|
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||||
|
const { buildRoverTurn } = require('../turnService/display');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||||
const {
|
const {
|
||||||
getPublicState: getPtzCameraState,
|
getPublicState: getPtzCameraState,
|
||||||
getChatTargetForSocket: getPtzChatTargetForSocket,
|
getOperatingModeDisplay,
|
||||||
PTZ_CAMERA_ID,
|
getParticipantSocketIds,
|
||||||
ptzCameraEvents,
|
ptzCameraEvents,
|
||||||
} = require('../ptzCameraService');
|
} = require('../ptzCameraService');
|
||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||||
@@ -111,18 +113,13 @@ function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
|
|||||||
|
|
||||||
function countControllableUsers(userEntries = []) {
|
function countControllableUsers(userEntries = []) {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
|
const ptzParticipants = new Set(getParticipantSocketIds());
|
||||||
userEntries.forEach((entry) => {
|
userEntries.forEach((entry) => {
|
||||||
const role = String(entry?.role || '');
|
const role = String(entry?.role || '');
|
||||||
if (role === 'spectator') return;
|
if (role === 'spectator') return;
|
||||||
const socketId = String(entry?.socketId || '').trim();
|
const socketId = String(entry?.socketId || '').trim();
|
||||||
const roverId = String(entry?.roverId || '').trim();
|
const roverId = String(entry?.roverId || '').trim();
|
||||||
/*
|
if (socketId && (roverId || ptzParticipants.has(socketId))) ids.add(socketId);
|
||||||
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
|
|
||||||
id and normal drivers to their physical rover. Counting entries after that
|
|
||||||
normalization gives the browser the same conceptual "controllable users"
|
|
||||||
count it shows in the user/queue panels without duplicating PTZ UI logic.
|
|
||||||
*/
|
|
||||||
if (socketId && roverId) ids.add(socketId);
|
|
||||||
});
|
});
|
||||||
return ids.size;
|
return ids.size;
|
||||||
}
|
}
|
||||||
@@ -146,19 +143,14 @@ function buildUserEntry(socket) {
|
|||||||
&& roverManager.isDriver(assignment.roverId, socket)
|
&& roverManager.isDriver(assignment.roverId, socket)
|
||||||
? assignment.roverId
|
? assignment.roverId
|
||||||
: null;
|
: null;
|
||||||
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
|
|
||||||
return {
|
return {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
userId: socket?.data?.userId || null,
|
userId: socket?.data?.userId || null,
|
||||||
nickname: getNickname(socket) || null,
|
nickname: getNickname(socket) || null,
|
||||||
role,
|
role,
|
||||||
/*
|
operatingMode: getOperatingMode(socket),
|
||||||
PTZ is not inserted into the physical rover roster, but for chat and user
|
operatingModeDisplay: getOperatingModeDisplay(socket.id),
|
||||||
presence it should read like the user moved to a rover-like target. Prefer
|
roverId: verifiedPrimaryRover || verifiedAssignmentRover || null,
|
||||||
the PTZ chat target while the socket is queued or operating so presence,
|
|
||||||
queue lookup, and chat identity all agree.
|
|
||||||
*/
|
|
||||||
roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,15 +163,7 @@ function buildSession(socket) {
|
|||||||
const controllableUserCount = countControllableUsers(userEntries);
|
const controllableUserCount = countControllableUsers(userEntries);
|
||||||
const users = userEntries.map((entry) => ({
|
const users = userEntries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
/*
|
roverId: filterVisibleRoverId(socket, entry.roverId),
|
||||||
PTZ is intentionally not a roverManager record, so the normal physical
|
|
||||||
rover visibility filter would erase the user's PTZ chat target. Preserve
|
|
||||||
it here because getPtzChatTargetForSocket already applied the PTZ access
|
|
||||||
and queue/operator rules before buildUserEntry returned it.
|
|
||||||
*/
|
|
||||||
roverId: entry.roverId === PTZ_CAMERA_ID
|
|
||||||
? entry.roverId
|
|
||||||
: filterVisibleRoverId(socket, entry.roverId),
|
|
||||||
}));
|
}));
|
||||||
const roster = roverManager.getRosterForSocket(socket);
|
const roster = roverManager.getRosterForSocket(socket);
|
||||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||||
@@ -201,6 +185,7 @@ function buildSession(socket) {
|
|||||||
return {
|
return {
|
||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
role: getRole(socket),
|
role: getRole(socket),
|
||||||
|
operatingMode: getOperatingMode(socket),
|
||||||
mode: getMode(),
|
mode: getMode(),
|
||||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||||
@@ -210,15 +195,24 @@ function buildSession(socket) {
|
|||||||
of navigation and layout without maintaining another feature registry.
|
of navigation and layout without maintaining another feature registry.
|
||||||
*/
|
*/
|
||||||
features,
|
features,
|
||||||
roster,
|
roster: roster.map((rover) => ({
|
||||||
|
...rover,
|
||||||
|
permissions: { canControl: getRole(socket) !== 'spectator' && canDrive(rover.id, socket) },
|
||||||
|
turn: buildRoverTurn({
|
||||||
|
rover,
|
||||||
|
mode: getMode(),
|
||||||
|
socketId: socket?.id,
|
||||||
|
turnInfo: turnQueues[rover.id],
|
||||||
|
activeDriverId: activeDrivers[rover.id],
|
||||||
|
users,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
odometers: roverManager.getOdometersForSocket(socket),
|
odometers: roverManager.getOdometersForSocket(socket),
|
||||||
assignment: {
|
assignment: {
|
||||||
...assignment,
|
...assignment,
|
||||||
roverId: assignmentRoverId,
|
roverId: assignmentRoverId,
|
||||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||||
},
|
},
|
||||||
activeDrivers,
|
|
||||||
turnQueues,
|
|
||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
ptzCamera: getPtzCameraState(socket),
|
ptzCamera: getPtzCameraState(socket),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
@@ -280,10 +274,19 @@ function syncAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('session:setOperatingMode', async ({ operatingMode } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ ok: true, ...await setOperatingMode(socket, operatingMode) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
logger.info('New socket connected', socket.id);
|
logger.info('New socket connected', socket.id);
|
||||||
syncSocket(socket);
|
syncSocket(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
operatingModeEvents.on('change', () => syncAll());
|
||||||
|
|
||||||
roleEvents.on('change', ({ socket }) => {
|
roleEvents.on('change', ({ socket }) => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
logger.info('Role changed; syncing session', socket.id);
|
logger.info('Role changed; syncing session', socket.id);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const { describeQueue } = require('./queueDisplay');
|
||||||
|
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
...require('./labels'),
|
||||||
|
waiting: 'Someone else is driving',
|
||||||
|
active: 'You’re driving',
|
||||||
|
showName: 'Show rover name',
|
||||||
|
hideName: 'Hide rover name',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build this from visibility-filtered session data so private rover state stays private.
|
||||||
|
function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users }) {
|
||||||
|
const queue = turnInfo?.queue || [];
|
||||||
|
// Direct ownership can arrive before queue details on load/reconnect.
|
||||||
|
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
||||||
|
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
||||||
|
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
||||||
|
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
||||||
|
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
||||||
|
const display = describeQueue(queue, currentDriverId, users);
|
||||||
|
return {
|
||||||
|
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
|
||||||
|
...display,
|
||||||
|
// Null distinguishes a rover that has never had a queue from an empty queue.
|
||||||
|
queue: turnInfo ? display.queue : null,
|
||||||
|
enabled,
|
||||||
|
ownsControl: Boolean(socketId && currentDriverId === socketId),
|
||||||
|
turnsAhead,
|
||||||
|
queueLength: queue.length,
|
||||||
|
deadline: turnInfo?.deadline || null,
|
||||||
|
durationMs: TURN_DURATION_MS,
|
||||||
|
idleDeadline: turnInfo?.idleDeadline || null,
|
||||||
|
idleGraceMs: IDLE_TIMEOUT_MS,
|
||||||
|
labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildRoverTurn };
|
||||||
@@ -397,6 +397,7 @@ function advanceTurn(roverId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (queue.queue.length === 0) {
|
if (queue.queue.length === 0) {
|
||||||
|
queue.current = null;
|
||||||
stopRover(roverId);
|
stopRover(roverId);
|
||||||
clearTurnTimer(roverId, queue);
|
clearTurnTimer(roverId, queue);
|
||||||
setActiveDriver(roverId, null);
|
setActiveDriver(roverId, null);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
handoff: 'It’s your turn!',
|
||||||
|
idleWarning: 'Start driving or your turn will be skipped',
|
||||||
|
activeTimer: 'Your turn',
|
||||||
|
waitingTimer: 'Waiting',
|
||||||
|
untilTurn: 'until your turn',
|
||||||
|
timeRemaining: 'left',
|
||||||
|
showTimer: 'Show turn timer',
|
||||||
|
hideTimer: 'Hide turn timer',
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Queue entries are complete display records. No browser-side user lookup or
|
||||||
|
// naming fallback is needed, including when the queue comes from another server.
|
||||||
|
function describeQueue(socketIds, currentId, users) {
|
||||||
|
const byId = new Map(users.map((user) => [user.socketId, user]));
|
||||||
|
const currentIndex = currentId ? socketIds.indexOf(currentId) : -1;
|
||||||
|
return {
|
||||||
|
queue: socketIds.map((socketId) => {
|
||||||
|
const user = byId.get(socketId);
|
||||||
|
return {
|
||||||
|
socketId,
|
||||||
|
name: user?.nickname || user?.name || socketId.slice(0, 6) || 'unknown',
|
||||||
|
role: user?.role || null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
currentId,
|
||||||
|
nextId: socketIds.length > 1 ? socketIds[(currentIndex + 1) % socketIds.length] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { describeQueue };
|
||||||
@@ -157,11 +157,11 @@ function identifySocket(socket, payload = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Spectator-style pages carry identity too, but only the driver surface
|
Spectator-style pages carry identity too, but only the driver and PTZ surfaces
|
||||||
participates in duplicate-driver enforcement. This flag remains on the
|
participate in duplicate-driver enforcement. This flag remains on the
|
||||||
socket because it is connection-specific, not person-specific.
|
socket because it is connection-specific, not person-specific.
|
||||||
*/
|
*/
|
||||||
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
socket.data.identitySurface = ['driver', 'ptz'].includes(payload.identitySurface) ? payload.identitySurface : 'passive';
|
||||||
|
|
||||||
const result = identifyCanonicalSocket(socket, {
|
const result = identifyCanonicalSocket(socket, {
|
||||||
...payload,
|
...payload,
|
||||||
@@ -207,7 +207,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
|
|||||||
if (
|
if (
|
||||||
!currentSocket?.id ||
|
!currentSocket?.id ||
|
||||||
!currentUserId ||
|
!currentUserId ||
|
||||||
currentSocket.data?.identitySurface !== 'driver' ||
|
!['driver', 'ptz'].includes(currentSocket.data?.identitySurface) ||
|
||||||
!enforceForCurrentSocket
|
!enforceForCurrentSocket
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -215,7 +215,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
|
|||||||
|
|
||||||
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
|
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
|
||||||
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
||||||
if (candidate?.data?.identitySurface !== 'driver') return false;
|
if (!['driver', 'ptz'].includes(candidate?.data?.identitySurface)) return false;
|
||||||
return getUserIdForSocket(candidate) === currentUserId;
|
return getUserIdForSocket(candidate) === currentUserId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
|
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ export function ChatIdentity({ message, toolsToggle = null }) {
|
|||||||
bot
|
bot
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{message.roverId && (
|
{(message.roverId || message.operatingModeDisplay) && (
|
||||||
<RoverLabel
|
<RoverLabel
|
||||||
roverId={message.roverId}
|
roverId={message.roverId}
|
||||||
name={message.roverName}
|
name={message.operatingModeDisplay?.name || message.roverName}
|
||||||
color={message.roverColor}
|
color={message.operatingModeDisplay?.color || message.roverColor}
|
||||||
fallback={message.roverId}
|
fallback={message.roverId}
|
||||||
className="shrink-0 text-[0.7rem]"
|
className="shrink-0 text-[0.7rem]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,33 +51,19 @@ function resolveTtsSettings(settings) {
|
|||||||
|
|
||||||
function useChatComposerSessionState(allowSpectatorInput) {
|
function useChatComposerSessionState(allowSpectatorInput) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
|
|
||||||
const chatTargetId = useMemo(() => {
|
|
||||||
/*
|
|
||||||
PTZ is intentionally not a roster entry, but session users expose the
|
|
||||||
current chat target for each socket. Prefer the self user entry so the TTS
|
|
||||||
control follows PTZ queue/operation state instead of only physical rover
|
|
||||||
assignment state.
|
|
||||||
*/
|
|
||||||
const self = users.find((entry) => entry?.socketId === socketId);
|
|
||||||
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
|
|
||||||
return self?.roverId || currentRoverId || null;
|
|
||||||
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
|
|
||||||
|
|
||||||
const rover = useMemo(
|
const rover = useMemo(
|
||||||
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||||
[chatTargetId, roster],
|
[currentRoverId, roster],
|
||||||
);
|
);
|
||||||
const ptzTtsSupported = Boolean(
|
const ptzTtsSupported = Boolean(
|
||||||
ptz?.id &&
|
operatingMode === 'ptz' &&
|
||||||
String(chatTargetId) === String(ptz.id) &&
|
|
||||||
ptz?.audio?.enabled &&
|
ptz?.audio?.enabled &&
|
||||||
(ptz?.isOperator || ptz?.queuedPosition),
|
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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` }}>
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ function detectSafari() {
|
|||||||
|
|
||||||
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
|
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
const users = useSessionSelector((state) => state.session?.users || []);
|
|
||||||
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
||||||
@@ -49,26 +48,14 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
|||||||
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||||
const canChat = role !== 'spectator';
|
const canChat = role !== 'spectator';
|
||||||
const hideHudChat = role === 'spectator';
|
const hideHudChat = role === 'spectator';
|
||||||
const chatTargetId = useMemo(() => {
|
|
||||||
/*
|
|
||||||
HUD chat does not render the full composer controls, but it still sends
|
|
||||||
TTS payloads when the active target supports speech. PTZ appears only in
|
|
||||||
the session user target, not the physical rover roster, so use the same
|
|
||||||
self-target resolution as the full chat panel.
|
|
||||||
*/
|
|
||||||
const self = users.find((entry) => entry?.socketId === socketId);
|
|
||||||
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
|
|
||||||
return self?.roverId || currentRoverId || null;
|
|
||||||
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
|
|
||||||
const rover = useMemo(
|
const rover = useMemo(
|
||||||
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||||
[chatTargetId, roverRoster],
|
[currentRoverId, roverRoster],
|
||||||
);
|
);
|
||||||
const ptzTtsSupported = Boolean(
|
const ptzTtsSupported = Boolean(
|
||||||
ptz?.id &&
|
operatingMode === 'ptz' &&
|
||||||
String(chatTargetId) === String(ptz.id) &&
|
|
||||||
ptz?.audio?.enabled &&
|
ptz?.audio?.enabled &&
|
||||||
(ptz?.isOperator || ptz?.queuedPosition),
|
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
|
||||||
);
|
);
|
||||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
||||||
const ttsPayload = useMemo(() => {
|
const ttsPayload = useMemo(() => {
|
||||||
|
|||||||
@@ -40,10 +40,8 @@ function HudOverlay({
|
|||||||
});
|
});
|
||||||
const derivedDriverLabel = useSessionSelector((state) => {
|
const derivedDriverLabel = useSessionSelector((state) => {
|
||||||
if (!effectiveRoverId || variant !== 'spectator') return null;
|
if (!effectiveRoverId || variant !== 'spectator') return null;
|
||||||
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
|
const turn = state.session?.roster?.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
|
||||||
const users = state.session?.users || [];
|
return turn?.queue?.find((entry) => entry.socketId === turn.currentId)?.name || null;
|
||||||
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
|
||||||
return match?.nickname || match?.name || null;
|
|
||||||
});
|
});
|
||||||
const resolvedSensors = sensors ?? null;
|
const resolvedSensors = sensors ?? null;
|
||||||
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ import SocialButton from '../../SocialButton/index.jsx';
|
|||||||
function TurnsOverlay({
|
function TurnsOverlay({
|
||||||
roverId = null,
|
roverId = null,
|
||||||
mobileHud = false,
|
mobileHud = false,
|
||||||
turnModel = null,
|
|
||||||
}) {
|
}) {
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
|
||||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||||
@@ -30,30 +27,15 @@ function TurnsOverlay({
|
|||||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||||
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||||
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||||
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
const turnInfo = roster.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
|
||||||
const activeDriverId = turnModel
|
const activeDriverId = turnInfo?.currentId || null;
|
||||||
? turnModel.activeId || null
|
const isActiveDriver = Boolean(turnInfo?.ownsControl);
|
||||||
: effectiveRoverId
|
const isTurnsMode = mode === 'turns';
|
||||||
? activeDrivers?.[effectiveRoverId] || null
|
|
||||||
: null;
|
|
||||||
const isActiveDriver = turnModel
|
|
||||||
? Boolean(turnModel.isActive)
|
|
||||||
: Boolean(socketId && activeDriverId === socketId);
|
|
||||||
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
|
|
||||||
const now = useSharedClock(1000, isTurnsMode);
|
const now = useSharedClock(1000, isTurnsMode);
|
||||||
const nextDriverId = useMemo(() => {
|
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
|
||||||
if (turnModel) return turnModel.nextId || null;
|
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||||
const queue = turnInfo?.queue || [];
|
const deadline = turnInfo?.deadline || null;
|
||||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
|
||||||
if (idx === -1) return queue[0] || null;
|
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
|
||||||
}, [turnInfo, turnModel]);
|
|
||||||
const isNextDriver = turnModel
|
|
||||||
? Boolean(turnModel.isNext)
|
|
||||||
: Boolean(socketId && nextDriverId === socketId);
|
|
||||||
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
|
|
||||||
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
|
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
const msUntilTurn = deadline ? deadline - now : null;
|
||||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||||
const totalRovers = roster.length;
|
const totalRovers = roster.length;
|
||||||
@@ -69,14 +51,10 @@ function TurnsOverlay({
|
|||||||
});
|
});
|
||||||
return unique.size;
|
return unique.size;
|
||||||
}, [users]);
|
}, [users]);
|
||||||
const shouldUsePreviewByLoad = turnModel
|
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
||||||
? Boolean(turnModel.showPreviewReason)
|
|
||||||
: isTurnsMode && totalDrivers > totalRovers;
|
|
||||||
const isPreSwitchWindow =
|
const isPreSwitchWindow =
|
||||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||||
const showNotTurnNotice = turnModel
|
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||||
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
|
|
||||||
: isTurnsMode && !isActiveDriver;
|
|
||||||
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||||
const turnSeconds =
|
const turnSeconds =
|
||||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||||
@@ -105,12 +83,9 @@ function TurnsOverlay({
|
|||||||
const turnTimerFlashActive = noticeFlashActive;
|
const turnTimerFlashActive = noticeFlashActive;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
// The legacy overlay still follows the server's turns mode; ownership
|
||||||
Rover turns and PTZ turns now arrive through the same render path. Use the
|
// and deadlines now come from the same turn object as the new HUD.
|
||||||
normalized isTurnsMode flag here instead of checking the server's rover
|
|
||||||
mode directly, otherwise PTZ can render the notice but never trigger the
|
|
||||||
"your turn" cue when camera ownership changes.
|
|
||||||
*/
|
|
||||||
if (!isTurnsMode) {
|
if (!isTurnsMode) {
|
||||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -6,33 +6,11 @@ import { useControlActions, useControlSelector } from '../../../../controls/inde
|
|||||||
import ControlHint from '../../../ControlHint/index.jsx';
|
import ControlHint from '../../../ControlHint/index.jsx';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
import RoundControl from './RoundControl.jsx';
|
||||||
|
import PeripheralPod from './PeripheralPod.jsx';
|
||||||
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
|
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
|
||||||
import usePodVisibility from './usePodVisibility.js';
|
import usePodVisibility from './usePodVisibility.js';
|
||||||
|
|
||||||
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
|
|
||||||
const ControlIcon = icon;
|
|
||||||
const toneClass = tone === 'horn'
|
|
||||||
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
|
||||||
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
|
||||||
>
|
|
||||||
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
|
||||||
<KeyPill label={keyLabel} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BottomLeftPod({ roverId }) {
|
export default function BottomLeftPod({ roverId }) {
|
||||||
const [open, setOpen] = usePodVisibility('peripherals', true);
|
const [open, setOpen] = usePodVisibility('peripherals', true);
|
||||||
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
|
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
|
||||||
@@ -67,30 +45,25 @@ export default function BottomLeftPod({ roverId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open ? (
|
<PeripheralPod open={open} onOpenChange={setOpen} label="rover controls">
|
||||||
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
|
{/*
|
||||||
{/*
|
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
||||||
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
||||||
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
sixty-degree separation leaves a visible gap between the small circles while keeping
|
||||||
sixty-degree separation leaves a visible gap between the small circles while keeping
|
both controls equally distant from the horn. These explicit positions are the
|
||||||
both controls equally distant from the horn. These explicit positions are the
|
rendered result of that geometry, not unrelated visual nudges.
|
||||||
rendered result of that geometry, not unrelated visual nudges.
|
*/}
|
||||||
*/}
|
{/* Physical rover actions become visibly and behaviorally unavailable
|
||||||
{/* Physical rover actions become visibly and behaviorally unavailable
|
while another queued driver owns the turn. Pod/settings controls
|
||||||
while another queued driver owns the turn. Pod/settings controls
|
remain interactive because they do not mutate rover hardware. */}
|
||||||
remain interactive because they do not mutate rover hardware. */}
|
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
||||||
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
|
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
||||||
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
|
{/* The room-light lock deliberately blocks laser activation because
|
||||||
{/* The room-light lock deliberately blocks laser activation because
|
the laser is only intended for use while the room is dark. This
|
||||||
the laser is only intended for use while the room is dark. This
|
mirrors the old desktop control's visible disabled state; turn
|
||||||
mirrors the old desktop control's visible disabled state; turn
|
ownership remains the other independent control restriction. */}
|
||||||
ownership remains the other independent control restriction. */}
|
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
</PeripheralPod>
|
||||||
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
|
|
||||||
)}
|
|
||||||
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
|
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FaVideo } from 'react-icons/fa';
|
|||||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||||
import ControlHint from '../../../ControlHint/index.jsx';
|
import ControlHint from '../../../ControlHint/index.jsx';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
import ChatExpansion from './ChatExpansion.jsx';
|
import ChatExpansion from './ChatExpansion.jsx';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
@@ -28,7 +28,7 @@ function pointOnArc(fraction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BottomRightPod({ roverId }) {
|
export default function BottomRightPod({ roverId }) {
|
||||||
const layout = useDriverLayout();
|
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 dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
|
|
||||||
|
export default function PeripheralPod({ open, onOpenChange, label, children, className = 'h-40 w-40' }) {
|
||||||
|
return open ? (
|
||||||
|
<div className={`pointer-events-auto absolute bottom-0 left-0 z-20 rounded-tr-[4.25rem] bg-black/60 ${className}`}>
|
||||||
|
{children}
|
||||||
|
<CornerPodToggle corner="bottom-left" expanded label={`Hide ${label}`} onClick={() => onOpenChange(false)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CornerPodToggle corner="bottom-left" expanded={false} label={`Show ${label}`} onClick={() => onOpenChange(true)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
|
|
||||||
|
export default function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '', value = null }) {
|
||||||
|
const ControlIcon = icon;
|
||||||
|
const toneClass = tone === 'horn'
|
||||||
|
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||||
|
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
||||||
|
>
|
||||||
|
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
||||||
|
{value != null ? <span className="text-xs font-semibold leading-none">{value}</span> : null}
|
||||||
|
<KeyPill label={keyLabel} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,40 +1,28 @@
|
|||||||
// Top-left Corner Pod
|
// Top-left Corner Pod
|
||||||
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
|
// Purpose: Renders the caller's turn-display object without knowing its operating mode.
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useControlSelector } from '../../../../controls/index.js';
|
import { useControlSelector } from '../../../../controls/index.js';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
|
||||||
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
|
||||||
import RoverLabel from '../../../RoverLabel/index.jsx';
|
import RoverLabel from '../../../RoverLabel/index.jsx';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
import ExpansionPanel from './ExpansionPanel.jsx';
|
import ExpansionPanel from './ExpansionPanel.jsx';
|
||||||
import usePodVisibility from './usePodVisibility.js';
|
import usePodVisibility from './usePodVisibility.js';
|
||||||
|
|
||||||
export default function TopLeftPod({ roverId }) {
|
export default function TopLeftPod({ turns, compact = false }) {
|
||||||
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
||||||
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
|
|
||||||
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
|
|
||||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||||
const deadline = turnInfo?.deadline || null;
|
const { target, labels, enabled: turnActive, queueLength, turnsAhead, ownsControl: isCurrentTurn } = turns;
|
||||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
const deadline = turns.deadline;
|
||||||
const queue = turnInfo?.queue || [];
|
const idleDeadline = turns.idleDeadline;
|
||||||
// Direct ownership is published independently from the detailed queue and is
|
const durationSeconds = turns.durationMs / 1000;
|
||||||
// therefore the reliable initial-load/reconnect source for the current turn.
|
|
||||||
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
|
||||||
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
|
||||||
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
|
||||||
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
|
||||||
const hasTurnDeadline = Boolean(turnActive && deadline);
|
const hasTurnDeadline = Boolean(turnActive && deadline);
|
||||||
const now = useSharedClock(1000, hasTurnDeadline);
|
const now = useSharedClock(1000, hasTurnDeadline);
|
||||||
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
|
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
|
||||||
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
|
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
|
||||||
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
|
||||||
const seconds = currentTurnSeconds == null || turnsAhead == null
|
const seconds = currentTurnSeconds == null || turnsAhead == null
|
||||||
? null
|
? null
|
||||||
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
|
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * durationSeconds;
|
||||||
const isCurrentTurn = turnsAhead === 0;
|
|
||||||
const isWaitingForTurn = turnActive && !isCurrentTurn;
|
const isWaitingForTurn = turnActive && !isCurrentTurn;
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||||
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
|
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
|
||||||
@@ -81,25 +69,20 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
|
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
|
||||||
const gaugePercent = useMemo(() => {
|
const gaugePercent = useMemo(() => {
|
||||||
if (seconds == null) return 0;
|
if (seconds == null) return 0;
|
||||||
/*
|
// Scale waiting time by this target's actual turn duration. PTZ and rover
|
||||||
The server's rover turns are sixty seconds long. Waiting users need one complete
|
// turns have different lengths, but use the same presentation.
|
||||||
turn added for each driver between the current driver and themselves. A complete
|
const rotationSeconds = Math.max(durationSeconds, queueLength * durationSeconds);
|
||||||
queue rotation is a stable scale across handoffs, so the ring drains continuously
|
|
||||||
instead of jumping back to full when the current driver changes.
|
|
||||||
*/
|
|
||||||
const rotationSeconds = Math.max(60, queue.length * 60);
|
|
||||||
return Math.max(0, Math.min(1, seconds / rotationSeconds));
|
return Math.max(0, Math.min(1, seconds / rotationSeconds));
|
||||||
}, [queue.length, seconds]);
|
}, [durationSeconds, queueLength, seconds]);
|
||||||
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
|
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
|
||||||
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
|
// The caller supplies its inactivity grace alongside the idle deadline.
|
||||||
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
|
? Math.max(0, Math.min(1, idleSkipSeconds / (turns.idleGraceMs / 1000)))
|
||||||
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
|
|
||||||
: gaugePercent;
|
: gaugePercent;
|
||||||
const showTimer = Boolean(turnActive && timerOpen);
|
const showTimer = Boolean(turnActive && timerOpen);
|
||||||
const showLargeTimer = isWaitingForTurn || showTurnCue;
|
const showLargeTimer = isWaitingForTurn || showTurnCue;
|
||||||
const timerLabel = seconds == null
|
const timerLabel = seconds == null
|
||||||
// Ownership should remain visible while the detailed deadline is in flight.
|
// Ownership should remain visible while the detailed deadline is in flight.
|
||||||
? isCurrentTurn ? 'Your turn' : 'Waiting'
|
? isCurrentTurn ? labels.activeTimer : labels.waitingTimer
|
||||||
: seconds >= 60
|
: seconds >= 60
|
||||||
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||||
: `${seconds}s`;
|
: `${seconds}s`;
|
||||||
@@ -113,8 +96,8 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
// The waiting/handoff state replaces the old full-screen turn cue.
|
// The waiting/handoff state replaces the old full-screen turn cue.
|
||||||
// It must be opaque and above every other in-video HUD surface so
|
// It must be opaque and above every other in-video HUD surface so
|
||||||
// sensor graphics, chat, and docking controls cannot muddy the text.
|
// sensor graphics, chat, and docking controls cannot muddy the text.
|
||||||
? 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
? compact ? 'h-[min(12rem,45vw)] w-[min(12rem,45vw)] rounded-br-[6rem] bg-black' : 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
||||||
: 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
: compact ? 'h-24 w-24 rounded-br-[3rem] bg-black/60' : 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
||||||
@@ -129,31 +112,31 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
fixed-size so it never becomes a giant obstruction over the video. */}
|
fixed-size so it never becomes a giant obstruction over the video. */}
|
||||||
{showLargeTimer ? (
|
{showLargeTimer ? (
|
||||||
<>
|
<>
|
||||||
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
<span className={`${compact ? 'mb-1 text-sm' : 'mb-2 text-[1.75rem]'} font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
||||||
{showTurnCue ? 'It’s your turn!' : 'Someone else is driving'}
|
{showTurnCue ? labels.handoff : labels.waiting}
|
||||||
</span>
|
</span>
|
||||||
<strong className="text-[3.375rem] leading-none">
|
<strong className={`${compact ? "text-2xl" : "text-[3.375rem]"} leading-none`}>
|
||||||
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
|
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
|
||||||
</strong>
|
</strong>
|
||||||
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
<span className={`${compact ? 'mt-1 text-xs' : 'mt-2 text-[1rem]'} font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
||||||
{showTurnCue
|
{showTurnCue
|
||||||
? idleSkipSeconds != null
|
? idleSkipSeconds != null
|
||||||
? 'Start driving or your turn will be skipped'
|
? labels.idleWarning
|
||||||
: 'You’re driving'
|
: labels.active
|
||||||
: 'until your turn'}
|
: labels.untilTurn}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<strong className="text-lg leading-none">{timerLabel}</strong>
|
<strong className="text-lg leading-none">{timerLabel}</strong>
|
||||||
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
|
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">{labels.timeRemaining}</span> : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
|
<CornerPodToggle corner="top-left" expanded label={labels.hideTimer} onClick={() => setTimerOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
) : turnActive ? (
|
) : turnActive ? (
|
||||||
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
|
<CornerPodToggle corner="top-left" expanded={false} label={labels.showTimer} onClick={() => setTimerOpen(true)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* The rover name is an independent edge expansion. Its visibility control lives in
|
{/* The rover name is an independent edge expansion. Its visibility control lives in
|
||||||
@@ -166,10 +149,10 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
panelClassName={`flex h-11 min-w-max items-center gap-2 bg-black/60 px-2 pt-3 ${showTimer ? '' : 'rounded-br-xl'}`}
|
panelClassName={`flex h-11 min-w-max items-center gap-2 bg-black/60 px-2 pt-3 ${showTimer ? '' : 'rounded-br-xl'}`}
|
||||||
openDirection="down"
|
openDirection="down"
|
||||||
closeDirection="up"
|
closeDirection="up"
|
||||||
openLabel="Show rover name"
|
openLabel={labels.showName}
|
||||||
closeLabel="Hide rover name"
|
closeLabel={labels.hideName}
|
||||||
>
|
>
|
||||||
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
|
<RoverLabel name={target.name} color={target.color} fallback={target.fallback} className="px-2 py-1 text-base" />
|
||||||
</ExpansionPanel>
|
</ExpansionPanel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
// New Drive Corner Pods
|
// New Drive Corner Pods
|
||||||
// Purpose: Composes the four independently owned corner controls around the shared video stage.
|
// Purpose: Composes the four independently owned corner controls around the shared video stage.
|
||||||
import TopLeftPod from './TopLeftPod.jsx';
|
import TopLeftPod from './TopLeftPod.jsx';
|
||||||
|
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||||
import TopRightPod from './TopRightPod.jsx';
|
import TopRightPod from './TopRightPod.jsx';
|
||||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||||
import BottomRightPod from './BottomRightPod.jsx';
|
import BottomRightPod from './BottomRightPod.jsx';
|
||||||
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
||||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
|
|
||||||
export default function CornerPods({ roverId }) {
|
export default function CornerPods({ roverId }) {
|
||||||
const layout = useDriverLayout();
|
const layout = useLayout();
|
||||||
|
const turns = useSessionSelector((state) => state.session?.roster
|
||||||
|
?.find((rover) => String(rover.id) === String(roverId))?.turn);
|
||||||
const showPhysicalControlPods = layout === 'desktop';
|
const showPhysicalControlPods = layout === 'desktop';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopLeftPod roverId={roverId} />
|
{turns ? <TopLeftPod turns={turns} /> : null}
|
||||||
<TopRightPod roverId={roverId} />
|
<TopRightPod roverId={roverId} />
|
||||||
{/* The mobile layouts already provide large touch controls around the video.
|
{/* The mobile layouts already provide large touch controls around the video.
|
||||||
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/tel
|
|||||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.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 { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
|
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
|
||||||
@@ -250,7 +250,7 @@ function UndockTransitionGhost({ onFinish }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DockingHud({ roverId }) {
|
export default function DockingHud({ roverId }) {
|
||||||
const layout = useDriverLayout();
|
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
|
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
||||||
|
|||||||
@@ -170,8 +170,6 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
|
|||||||
key={remote.url}
|
key={remote.url}
|
||||||
title={remote.instance?.name || remote.url}
|
title={remote.instance?.name || remote.url}
|
||||||
roster={remote.roster}
|
roster={remote.roster}
|
||||||
turnQueues={remote.turnQueues}
|
|
||||||
users={remote.users}
|
|
||||||
externalInstance={remote}
|
externalInstance={remote}
|
||||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||||
/>
|
/>
|
||||||
@@ -221,8 +219,6 @@ function InterInstanceCards({ instances, centered = false, singleColumn = false
|
|||||||
<RemoteMediaStrip remote={remote} />
|
<RemoteMediaStrip remote={remote} />
|
||||||
<RoverQueuesPanel
|
<RoverQueuesPanel
|
||||||
roster={remote.roster}
|
roster={remote.roster}
|
||||||
turnQueues={remote.turnQueues}
|
|
||||||
users={remote.users}
|
|
||||||
externalInstance={remote}
|
externalInstance={remote}
|
||||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import BottomSensorHud from '../HudOverlays/newgen/BottomSensorHud/index.jsx';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useControlSelector } from '../../controls/index.js';
|
import { useControlSelector } from '../../controls/index.js';
|
||||||
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
||||||
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../layouts/LayoutContext.jsx';
|
||||||
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
||||||
|
|
||||||
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
|
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
|
||||||
@@ -21,7 +21,7 @@ import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
|||||||
const MOBILE_HUD_SCALE = 0.68;
|
const MOBILE_HUD_SCALE = 0.68;
|
||||||
|
|
||||||
export default function NewDriveVideo() {
|
export default function NewDriveVideo() {
|
||||||
const layout = useDriverLayout();
|
const layout = useLayout();
|
||||||
const mobileHud = layout !== 'desktop';
|
const mobileHud = layout !== 'desktop';
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const videoMode = useDriverVideoModePolicy(roverId);
|
const videoMode = useDriverVideoModePolicy(roverId);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
export default function OperatingModeRouteSync() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const previousPath = useRef(pathname);
|
||||||
|
const { setOperatingMode, pushAlert } = useSessionActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const leavingPtz = previousPath.current === '/ptz' && pathname !== '/ptz';
|
||||||
|
previousPath.current = pathname;
|
||||||
|
if (!leavingPtz) return;
|
||||||
|
// Observe actual navigation rather than component cleanup, which also runs
|
||||||
|
// during Strict Mode remounts. Entry remains gated by the PTZ request action.
|
||||||
|
setOperatingMode('rover').catch((err) => {
|
||||||
|
pushAlert({ title: 'Operating mode', message: err.message, color: '#f59e0b' });
|
||||||
|
});
|
||||||
|
}, [pathname, pushAlert, setOperatingMode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,32 +1,13 @@
|
|||||||
// PTZ Camera UI
|
// Driver-page entry card; PTZ route lifecycle belongs to the PTZ page.
|
||||||
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a
|
import { useState } from 'react';
|
||||||
// queueable controllable target instead of a VIP-panel card.
|
|
||||||
// Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
|
|
||||||
// composition; PTZ command authority, queue ownership, and stream authorization
|
|
||||||
// remain server-owned.
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
|
||||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
|
||||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
|
||||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
|
||||||
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
|
|
||||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
|
||||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
|
||||||
import { useControlActions } from '../../controls/index.js';
|
|
||||||
import ControlHint from '../ControlHint/index.jsx';
|
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
|
||||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import { isFeatureEnabled } from '../../lib/features.js';
|
import { isFeatureEnabled } from '../../lib/features.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
const PTZ_DEFAULT_COLOR = '#387bf8';
|
||||||
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../../themes/index.js';
|
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
|
||||||
|
|
||||||
const PTZ_DEFAULT_COLOR = '#38bdf8';
|
|
||||||
|
|
||||||
function formatRemaining(deadline, now) {
|
function formatRemaining(deadline, now) {
|
||||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - now) / 1000));
|
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - now) / 1000));
|
||||||
@@ -36,840 +17,17 @@ function formatRemaining(deadline, now) {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSpotlightOn(light = {}) {
|
|
||||||
if (typeof light?.on === 'boolean') return light.on;
|
|
||||||
const raw = light?.state;
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const normalized = raw.trim().toLowerCase();
|
|
||||||
return !['', '0', 'off', 'false'].includes(normalized);
|
|
||||||
}
|
|
||||||
return Boolean(Number(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIrMode(mode) {
|
|
||||||
const normalized = String(mode || '').trim().toLowerCase();
|
|
||||||
if (normalized === 'on') return 'On';
|
|
||||||
if (normalized === 'off') return 'Off';
|
|
||||||
return 'Auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextIrMode(currentMode) {
|
|
||||||
const current = normalizeIrMode(currentMode);
|
|
||||||
if (current === 'Auto') return 'On';
|
|
||||||
if (current === 'On') return 'Off';
|
|
||||||
return 'Auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePtzQueue(ptz = null) {
|
|
||||||
/*
|
|
||||||
The PTZ service exposes the current operator separately from the waiting
|
|
||||||
queue, while the rover queue row expects one ordered queue plus a current id.
|
|
||||||
Normalizing once keeps every PTZ surface consistent with the shared queue
|
|
||||||
renderer without making that renderer understand PTZ service internals.
|
|
||||||
*/
|
|
||||||
const currentId = ptz?.operatorSocketId || null;
|
|
||||||
const waiting = Array.isArray(ptz?.queue)
|
|
||||||
? ptz.queue.map((entry) => entry?.socketId || entry).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const queue = currentId ? [currentId, ...waiting.filter((id) => id !== currentId)] : waiting;
|
|
||||||
const nextId = currentId ? waiting[0] || null : queue[0] || null;
|
|
||||||
return { queue, currentId, nextId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePtzQueueLookup(ptz = null) {
|
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
return useCallback(
|
|
||||||
(socketId) => {
|
|
||||||
const fromUsers = users.find((entry) => entry.socketId === socketId);
|
|
||||||
if (fromUsers) return fromUsers;
|
|
||||||
if (ptz?.operatorSocketId === socketId) {
|
|
||||||
return { socketId, nickname: ptz?.operatorLabel || null, role: null };
|
|
||||||
}
|
|
||||||
const fromQueue = Array.isArray(ptz?.queue)
|
|
||||||
? ptz.queue.find((entry) => (entry?.socketId || entry) === socketId)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
socketId,
|
|
||||||
nickname: fromQueue?.label || null,
|
|
||||||
role: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[ptz, users],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full' }) {
|
|
||||||
return (
|
|
||||||
<div className={`relative overflow-hidden bg-black ${className}`}>
|
|
||||||
{feed?.objectUrl ? (
|
|
||||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
|
||||||
)}
|
|
||||||
{/*
|
|
||||||
Snapshot mode should look like the regular rover video player: the
|
|
||||||
camera name belongs to the surrounding card/menu, while the media pane
|
|
||||||
only exposes stream health in the small top-left diagnostic overlay.
|
|
||||||
*/}
|
|
||||||
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const lookupUser = usePtzQueueLookup(ptz);
|
|
||||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
|
||||||
<QueueUserChips
|
|
||||||
targetId={ptz?.id || PTZ_CAMERA_ID}
|
|
||||||
queue={queue}
|
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
|
||||||
lookupUser={lookupUser}
|
|
||||||
/>
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzLightingControls({ ptz, disabled = false }) {
|
|
||||||
const { ptzSpotlight, ptzIr } = useSessionActions();
|
|
||||||
const [busy, setBusy] = useState('');
|
|
||||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
|
||||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
|
||||||
|
|
||||||
const toggleSpotlight = async (nextOn) => {
|
|
||||||
if (disabled) return;
|
|
||||||
setBusy('spotlight');
|
|
||||||
try {
|
|
||||||
await ptzSpotlight({ state: nextOn ? 1 : 0 });
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleIr = async () => {
|
|
||||||
if (disabled) return;
|
|
||||||
triggerTouchHaptic('button');
|
|
||||||
setBusy('ir');
|
|
||||||
try {
|
|
||||||
await ptzIr({ state: nextIrMode(irMode) });
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
||||||
<GPIOToggleControl
|
|
||||||
label="Spotlight"
|
|
||||||
on={spotlightOn}
|
|
||||||
disabled={disabled || busy === 'spotlight'}
|
|
||||||
onToggle={toggleSpotlight}
|
|
||||||
heightClass="min-h-14"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
|
|
||||||
disabled={disabled || busy === 'ir'}
|
|
||||||
onClick={cycleIr}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">Infrared</span>
|
|
||||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileZoomButtons({ disabled = false }) {
|
|
||||||
const { setCameraAxisIntent } = useControlActions();
|
|
||||||
|
|
||||||
const stopZoom = useCallback(() => {
|
|
||||||
/*
|
|
||||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
|
||||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
|
||||||
cannot erase the other finger's intent.
|
|
||||||
*/
|
|
||||||
setCameraAxisIntent(0);
|
|
||||||
}, [setCameraAxisIntent]);
|
|
||||||
|
|
||||||
const startZoom = useCallback(
|
|
||||||
(direction) => (event) => {
|
|
||||||
/*
|
|
||||||
Publish held state once. The adapter owns the single motion heartbeat,
|
|
||||||
so this button no longer creates a second interval whose queued callback
|
|
||||||
could run after pointerup and restart zoom.
|
|
||||||
*/
|
|
||||||
event.preventDefault();
|
|
||||||
if (disabled) return;
|
|
||||||
triggerTouchHaptic('button');
|
|
||||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
||||||
setCameraAxisIntent(direction);
|
|
||||||
},
|
|
||||||
[disabled, setCameraAxisIntent],
|
|
||||||
);
|
|
||||||
const stopFromPointer = useCallback(
|
|
||||||
(event) => {
|
|
||||||
event?.preventDefault?.();
|
|
||||||
if (disabled) return;
|
|
||||||
stopZoom();
|
|
||||||
},
|
|
||||||
[disabled, stopZoom],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
/*
|
|
||||||
A touch surface can unmount during orientation changes or fullscreen
|
|
||||||
close while a pointer is still down. Explicitly clear zoom here because
|
|
||||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
|
||||||
*/
|
|
||||||
setCameraAxisIntent(0);
|
|
||||||
},
|
|
||||||
[setCameraAxisIntent],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
onPointerDown={startZoom(-1)}
|
|
||||||
onPointerUp={stopFromPointer}
|
|
||||||
onPointerCancel={stopFromPointer}
|
|
||||||
onLostPointerCapture={stopFromPointer}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
Zoom out
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
onPointerDown={startZoom(1)}
|
|
||||||
onPointerUp={stopFromPointer}
|
|
||||||
onPointerCancel={stopFromPointer}
|
|
||||||
onLostPointerCapture={stopFromPointer}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
Zoom in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control space-y-0.5">
|
|
||||||
<PtzMobileZoomButtons disabled={disabled} />
|
|
||||||
<div className="mobile-touch-control h-44 min-h-0">
|
|
||||||
{/*
|
|
||||||
Reuse the rover control pad so touch intent still enters the normal
|
|
||||||
control system. The PTZ adapter translates that same drive vector into
|
|
||||||
pan/tilt commands only while this user is the PTZ operator.
|
|
||||||
*/}
|
|
||||||
<ControlPadPanel compact disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzControlReference() {
|
|
||||||
const rows = [
|
|
||||||
['Tilt up', 'driveForward'],
|
|
||||||
['Tilt down', 'driveBackward'],
|
|
||||||
['Pan left', 'driveLeft'],
|
|
||||||
['Pan right', 'driveRight'],
|
|
||||||
['Zoom in', 'cameraUp'],
|
|
||||||
['Zoom out', 'cameraDown'],
|
|
||||||
['Spotlight', 'headlightToggle'],
|
|
||||||
['Infrared mode', 'laserToggle'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
|
||||||
{rows.map(([label, actionId]) => (
|
|
||||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
|
||||||
<span className="text-slate-400">{label}</span>
|
|
||||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzPresetPanel({ ptz }) {
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
|
||||||
const {
|
|
||||||
ptzListPresets,
|
|
||||||
ptzGotoPreset,
|
|
||||||
ptzCreatePreset,
|
|
||||||
ptzRemovePreset,
|
|
||||||
} = useSessionActions();
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [busy, setBusy] = useState('');
|
|
||||||
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
|
|
||||||
const isPresetAdmin = role === 'admin' || role === 'lockdown';
|
|
||||||
const canCreatePreset = Boolean(ptz?.canUse);
|
|
||||||
const canMoveToPreset = Boolean(ptz?.isOperator);
|
|
||||||
|
|
||||||
const refreshPresets = async () => {
|
|
||||||
if (busy) return;
|
|
||||||
setBusy('refresh');
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
Presets live on the camera, not in browser state. A manual refresh gives
|
|
||||||
admins a simple recovery path if another admin or the camera's native
|
|
||||||
app changes preset storage while this UI is already open.
|
|
||||||
*/
|
|
||||||
await ptzListPresets();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to refresh PTZ presets.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPreset = async (preset) => {
|
|
||||||
if (!canMoveToPreset || busy || !preset?.token) return;
|
|
||||||
setBusy(`goto:${preset.token}`);
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
Moving to a preset is a physical camera move, so the server still checks
|
|
||||||
that this browser owns the active PTZ turn before accepting the command.
|
|
||||||
*/
|
|
||||||
await ptzGotoPreset({ token: preset.token });
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to move to PTZ preset.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPreset = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!canCreatePreset || busy || !trimmed) return;
|
|
||||||
setBusy('create');
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
ONVIF setPreset stores the camera's current physical position. The UI
|
|
||||||
only sends the user's label; the server supplies the active profile
|
|
||||||
token so browser code does not need camera profile internals, and the
|
|
||||||
server still enforces the PTZ feature gate for raw socket callers.
|
|
||||||
*/
|
|
||||||
await ptzCreatePreset({ name: trimmed });
|
|
||||||
setName('');
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to create PTZ preset.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePreset = async (preset) => {
|
|
||||||
if (!isPresetAdmin || busy || !preset?.token) return;
|
|
||||||
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
|
|
||||||
if (!confirmed) return;
|
|
||||||
setBusy(`remove:${preset.token}`);
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
The token is the camera's durable preset identifier. Names are only UI
|
|
||||||
labels and may not be unique, so deletion always targets the token.
|
|
||||||
*/
|
|
||||||
await ptzRemovePreset({ token: preset.token });
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to remove PTZ preset.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame
|
|
||||||
title="Position presets"
|
|
||||||
fillHeight
|
|
||||||
actions={(
|
|
||||||
<button type="button" className="button-dark text-xs" disabled={Boolean(busy)} onClick={refreshPresets}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
|
|
||||||
>
|
|
||||||
{ptz?.presetsError ? (
|
|
||||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
|
||||||
{ptz.presetsError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
|
|
||||||
{/*
|
|
||||||
Presets should behave like a compact pile of actions, not a table.
|
|
||||||
flex-wrap lets each preset keep its natural button width and only
|
|
||||||
starts a new visual line when the current line runs out of room.
|
|
||||||
*/}
|
|
||||||
{presets.length ? presets.map((preset) => {
|
|
||||||
const gotoBusy = busy === `goto:${preset.token}`;
|
|
||||||
const removeBusy = busy === `remove:${preset.token}`;
|
|
||||||
return (
|
|
||||||
<div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
|
|
||||||
disabled={!canMoveToPreset || Boolean(busy)}
|
|
||||||
onClick={() => goToPreset(preset)}
|
|
||||||
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
|
|
||||||
>
|
|
||||||
{gotoBusy ? 'Moving...' : preset.name}
|
|
||||||
</button>
|
|
||||||
{isPresetAdmin ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-dark text-xs text-rose-200 disabled:opacity-50"
|
|
||||||
disabled={Boolean(busy)}
|
|
||||||
onClick={() => removePreset(preset)}
|
|
||||||
>
|
|
||||||
{removeBusy ? 'Removing...' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}) : (
|
|
||||||
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
|
||||||
No presets saved.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{canCreatePreset ? (
|
|
||||||
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
|
|
||||||
<input
|
|
||||||
className="field-input min-w-0 text-xs"
|
|
||||||
value={name}
|
|
||||||
maxLength={60}
|
|
||||||
disabled={Boolean(busy)}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
placeholder="Preset name"
|
|
||||||
/>
|
|
||||||
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
|
|
||||||
{busy === 'create' ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPtzTurnModel(ptz, selfId) {
|
|
||||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
|
||||||
const isActive = Boolean(ptz?.isOperator);
|
|
||||||
const isQueued = Boolean(ptz?.queuedPosition);
|
|
||||||
return {
|
|
||||||
enabled: Boolean(ptz && (isActive || isQueued || currentId)),
|
|
||||||
targetId: ptz?.id || PTZ_CAMERA_ID,
|
|
||||||
activeId: currentId,
|
|
||||||
nextId,
|
|
||||||
isActive,
|
|
||||||
isNext: Boolean(selfId && nextId === selfId),
|
|
||||||
deadline: ptz?.deadline || null,
|
|
||||||
idleDeadline: null,
|
|
||||||
showNotTurnNotice: Boolean(!isActive && (isQueued || currentId || queue.length)),
|
|
||||||
showPreviewReason: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMediaPane({ ptz, open, framed = true }) {
|
|
||||||
const isOperator = Boolean(ptz?.isOperator);
|
|
||||||
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
|
|
||||||
const nonTurnSnapshotsActive = useSessionSelector(
|
|
||||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
|
||||||
);
|
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
/*
|
|
||||||
PTZ has its own turn queue, so non-operators are the camera equivalent of a
|
|
||||||
non-active rover driver. The server enforces the same policy in
|
|
||||||
canRequestLiveVideo(); this branch only chooses the expected browser render
|
|
||||||
path and never unlocks movement controls.
|
|
||||||
*/
|
|
||||||
/*
|
|
||||||
A direct /ptz load renders before its automatic queue claim is acknowledged.
|
|
||||||
Do not mount the live player during that short pre-claim window: its first
|
|
||||||
token request would correctly be rejected, and PtzLiveVideo intentionally
|
|
||||||
treats authorization rejection as a terminal snapshot fallback. Once the
|
|
||||||
session confirms queue/operator membership, mounting the player creates a
|
|
||||||
fresh authorized request without changing shared retry or server policy.
|
|
||||||
*/
|
|
||||||
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
|
|
||||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
|
||||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
|
||||||
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
|
||||||
const media = (
|
|
||||||
<>
|
|
||||||
{shouldUseLiveVideo ? (
|
|
||||||
<PtzLiveVideo enabled={open} startMuted={false} />
|
|
||||||
) : (
|
|
||||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
|
||||||
)}
|
|
||||||
<TurnsOverlay turnModel={turnModel} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!framed) {
|
|
||||||
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
|
|
||||||
<div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
|
|
||||||
{media}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzDesktopFullscreen({ ptz, releasePending }) {
|
|
||||||
return (
|
|
||||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(7rem,0.22fr)] gap-0.5 overflow-hidden p-0.5">
|
|
||||||
<div className="flex min-h-0 min-w-0 gap-0.5 overflow-hidden">
|
|
||||||
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
|
||||||
<PtzMediaPane ptz={ptz} open framed />
|
|
||||||
</main>
|
|
||||||
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
|
|
||||||
while the shared PTZ page theme can show through the same compact gaps as the driver
|
|
||||||
layout instead of being covered by one solid sidebar rectangle. */}
|
|
||||||
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
|
|
||||||
<PtzQueueSummary ptz={ptz} />
|
|
||||||
{ptz?.isOperator ? (
|
|
||||||
<PtzLightingControls ptz={ptz} />
|
|
||||||
) : (
|
|
||||||
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
|
||||||
Live PTZ controls unlock when your camera turn is active.
|
|
||||||
</CardFrame>
|
|
||||||
)}
|
|
||||||
<PtzControlReference />
|
|
||||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
|
||||||
{/*
|
|
||||||
Desktop keeps room lights as the final sidebar tool so camera
|
|
||||||
turn controls and replay remain above the less-frequent room-wide
|
|
||||||
actions. HomeAssistantControls owns its own feature and policy gate.
|
|
||||||
*/}
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
|
||||||
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
|
|
||||||
<PtzPresetPanel ptz={ptz} />
|
|
||||||
</div>
|
|
||||||
{releasePending ? (
|
|
||||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
|
|
||||||
Closing...
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
|
||||||
{/*
|
|
||||||
Landscape intentionally retains one control column beside the video.
|
|
||||||
This is the established PTZ interaction and avoids forcing rover-style
|
|
||||||
left/right columns onto a camera that has a smaller control inventory.
|
|
||||||
*/}
|
|
||||||
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
|
|
||||||
{/*
|
|
||||||
The video keeps one viewport of height, but the grid row is allowed to
|
|
||||||
grow when the control column is taller. That makes the sidebar's tail
|
|
||||||
extend below the video instead of forcing it into a nested scroller.
|
|
||||||
*/}
|
|
||||||
<div className="min-w-0 space-y-0.5">
|
|
||||||
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
|
||||||
disabled={releasePending}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
|
||||||
</main>
|
|
||||||
{/*
|
|
||||||
The right control column is naturally taller than the viewport.
|
|
||||||
Placing room lights after the fixed-height video uses that left-
|
|
||||||
column space while the whole landscape page continues scrolling as
|
|
||||||
one surface.
|
|
||||||
*/}
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</div>
|
|
||||||
{/*
|
|
||||||
Do not put overflow scrolling on this column. The surrounding PTZ
|
|
||||||
landscape content is the single page scroller, so a swipe over either
|
|
||||||
the video area or these controls advances the same document flow.
|
|
||||||
*/}
|
|
||||||
<aside className="min-h-0 space-y-0.5">
|
|
||||||
{/*
|
|
||||||
Landscape keeps all turn-critical controls in its one existing
|
|
||||||
sidebar. Queue position belongs first so the operator can confirm
|
|
||||||
control ownership before touching the camera, while replay follows
|
|
||||||
the lighting buttons because it is the next secondary action in
|
|
||||||
the same scroll column.
|
|
||||||
*/}
|
|
||||||
<PtzQueueSummary ptz={ptz} />
|
|
||||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
|
||||||
<ReplaySourcesPanel
|
|
||||||
panelId="ptz-controller-replay-mobile-landscape"
|
|
||||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
|
||||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
|
||||||
<PtzPresetPanel ptz={ptz} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
|
||||||
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
|
||||||
disabled={releasePending}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
|
||||||
</main>
|
|
||||||
{/*
|
|
||||||
Portrait gives the video its full available width and places controls
|
|
||||||
below it. Reusing the landscape sidebar width here was the source of the
|
|
||||||
cramped portrait presentation, while the controls themselves remain the
|
|
||||||
same shared PTZ controls used in landscape.
|
|
||||||
*/}
|
|
||||||
<section className="mobile-touch-control">
|
|
||||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
|
||||||
</section>
|
|
||||||
<section className="space-y-0.5">
|
|
||||||
{/*
|
|
||||||
Replay and presets are compact secondary actions, so portrait places
|
|
||||||
them in one equal-width row before the full-width queue and chat. The
|
|
||||||
explicit two-column grid keeps this arrangement local to portrait and
|
|
||||||
leaves the desktop and one-column landscape compositions unchanged.
|
|
||||||
*/}
|
|
||||||
<div className="grid grid-cols-2 items-start gap-0.5">
|
|
||||||
<ReplaySourcesPanel
|
|
||||||
panelId="ptz-controller-replay-mobile-portrait"
|
|
||||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
|
||||||
/>
|
|
||||||
<PtzPresetPanel ptz={ptz} />
|
|
||||||
</div>
|
|
||||||
<PtzQueueSummary ptz={ptz} />
|
|
||||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
|
||||||
{/* Portrait keeps room lights immediately after chat as requested. */}
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PtzControllerPage({ layout = 'desktop' }) {
|
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
|
||||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
|
||||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
|
|
||||||
const { stopAllMotion } = useControlActions();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [releasePending, setReleasePending] = useState(false);
|
|
||||||
const autoClaimSocketRef = useRef(null);
|
|
||||||
const routeExitReleaseTimerRef = useRef(null);
|
|
||||||
const participantRef = useRef(false);
|
|
||||||
const closingThroughButtonRef = useRef(false);
|
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
|
||||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
|
||||||
});
|
|
||||||
const isMobile = layout !== 'desktop';
|
|
||||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
|
||||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
|
||||||
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
|
|
||||||
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
|
|
||||||
// controls, and card interiors retain their purpose-built dark backgrounds.
|
|
||||||
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Route-exit cleanup runs after the last render, so retain the latest
|
|
||||||
// server-confirmed membership without making the lifecycle effect resubscribe.
|
|
||||||
participantRef.current = isParticipant;
|
|
||||||
}, [isParticipant]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (routeExitReleaseTimerRef.current) {
|
|
||||||
clearTimeout(routeExitReleaseTimerRef.current);
|
|
||||||
routeExitReleaseTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!participantRef.current || closingThroughButtonRef.current) return;
|
|
||||||
/*
|
|
||||||
Browser Back and route navigation unmount the PTZ page without invoking
|
|
||||||
its Close button. Defer release by one task so React Strict Mode's
|
|
||||||
development-only cleanup/remount cycle can cancel it in the next setup;
|
|
||||||
a real route exit has no replacement setup, so membership is released.
|
|
||||||
|
|
||||||
This is intentionally membership-gated. An admin release command can
|
|
||||||
revoke the current operator even when the admin is not that operator,
|
|
||||||
so an admin merely visiting/leaving a disabled or unjoined page must not
|
|
||||||
emit a release command.
|
|
||||||
*/
|
|
||||||
routeExitReleaseTimerRef.current = setTimeout(() => {
|
|
||||||
routeExitReleaseTimerRef.current = null;
|
|
||||||
ptzRelease().catch(() => {});
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
}, [ptzRelease]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
|
|
||||||
|
|
||||||
if (ptz.isOperator || ptz.queuedPosition) {
|
|
||||||
/*
|
|
||||||
Navigation from the driver queue normally arrives with membership
|
|
||||||
already established. Mark this socket complete so later session syncs
|
|
||||||
cannot turn that normal route transition into another claim request.
|
|
||||||
*/
|
|
||||||
autoClaimSocketRef.current = socketId;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoClaimSocketRef.current === socketId) return undefined;
|
|
||||||
autoClaimSocketRef.current = socketId;
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
/*
|
|
||||||
A direct /ptz load still receives the ordinary user role first, which can
|
|
||||||
briefly assign a rover. Claiming through the existing server action is
|
|
||||||
deliberate: ptzCameraService releases that rover ownership before it
|
|
||||||
activates or queues this socket, keeping one authoritative transition.
|
|
||||||
|
|
||||||
The socket-keyed ref suppresses repeats caused by session updates and
|
|
||||||
React's development effect replay. The server claim is also idempotent for
|
|
||||||
an existing operator/queue member, which covers an acknowledgement racing
|
|
||||||
with a fresh public-state sync.
|
|
||||||
*/
|
|
||||||
ptzClaim().catch((err) => {
|
|
||||||
if (!active) return;
|
|
||||||
pushAlert({
|
|
||||||
id: `ptz-auto-claim-${socketId}`,
|
|
||||||
title: 'PTZ camera',
|
|
||||||
message: err?.message || 'Unable to join the PTZ queue.',
|
|
||||||
color: '#f59e0b',
|
|
||||||
lifetimeMs: 6000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Do not emit or update UI from a rejected request after this route has
|
|
||||||
// unmounted; the server still owns completion of any request in flight.
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
|
|
||||||
|
|
||||||
const releaseAndClose = useCallback(async () => {
|
|
||||||
if (releasePending) return;
|
|
||||||
setReleasePending(true);
|
|
||||||
closingThroughButtonRef.current = true;
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
Stop first so a held key/pointer cannot leave ONVIF continuous movement
|
|
||||||
running while the server removes this socket from the PTZ queue.
|
|
||||||
*/
|
|
||||||
stopAllMotion?.();
|
|
||||||
if (ptz?.isOperator || ptz?.queuedPosition) {
|
|
||||||
await ptzRelease();
|
|
||||||
}
|
|
||||||
navigate('/');
|
|
||||||
} catch (err) {
|
|
||||||
// A rejected manual release leaves the route mounted, so route-exit
|
|
||||||
// cleanup must remain armed for a later Back/navigation attempt.
|
|
||||||
closingThroughButtonRef.current = false;
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setReleasePending(false);
|
|
||||||
}
|
|
||||||
}, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
|
|
||||||
|
|
||||||
if (!featureEnabled) {
|
|
||||||
return (
|
|
||||||
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
|
||||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
|
||||||
<p>The PTZ camera is not available.</p>
|
|
||||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
|
||||||
</CardFrame>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
|
||||||
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
|
|
||||||
transparent body is what lets every desktop and mobile PTZ composition share one
|
|
||||||
continuous pattern without threading theme props into each individual child panel. */}
|
|
||||||
<CardFrame
|
|
||||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
|
||||||
actions={isMobile ? null : (
|
|
||||||
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
hideHeader={isMobile}
|
|
||||||
fillHeight
|
|
||||||
clipOverflow={false}
|
|
||||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
|
||||||
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
layout === 'mobile-landscape' ? (
|
|
||||||
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
|
||||||
) : (
|
|
||||||
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
|
||||||
)}
|
|
||||||
</CardFrame>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PtzQueueCard() {
|
export default function PtzQueueCard() {
|
||||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
const { setOperatingMode } = useSessionActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const lookupUser = usePtzQueueLookup(ptz);
|
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
|
||||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
const canUse = Boolean(ptz?.permissions?.canEnter);
|
||||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
const isParticipant = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
|
||||||
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
|
const timerLabel = ptz?.turn?.ownsControl && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
|
||||||
|
|
||||||
if (!featureEnabled) return null;
|
if (!featureEnabled) return null;
|
||||||
|
|
||||||
@@ -881,13 +39,9 @@ export default function PtzQueueCard() {
|
|||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
const response = await ptzClaim();
|
const response = await setOperatingMode('ptz');
|
||||||
/*
|
// Enter first; the page requests its initial turn after navigation.
|
||||||
The server is authoritative for whether the click became an active turn
|
if (response?.operatingMode === 'ptz') {
|
||||||
or a queued wait. Open only after it confirms one of those states so a
|
|
||||||
dock-guard rejection does not strand the user in fullscreen.
|
|
||||||
*/
|
|
||||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
|
||||||
navigate('/ptz');
|
navigate('/ptz');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -901,7 +55,7 @@ export default function PtzQueueCard() {
|
|||||||
if (pending) return;
|
if (pending) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
await ptzRelease();
|
await setOperatingMode('rover');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || 'Failed to leave PTZ camera.');
|
alert(err.message || 'Failed to leave PTZ camera.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -909,13 +63,7 @@ export default function PtzQueueCard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionLabel = pending
|
const actionLabel = pending ? '...' : isParticipant ? 'Open' : 'request';
|
||||||
? '...'
|
|
||||||
: ptz?.isOperator
|
|
||||||
? 'Open'
|
|
||||||
: ptz?.queuedPosition
|
|
||||||
? 'Open'
|
|
||||||
: 'request';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
||||||
@@ -925,22 +73,14 @@ export default function PtzQueueCard() {
|
|||||||
id: ptz?.id || PTZ_CAMERA_ID,
|
id: ptz?.id || PTZ_CAMERA_ID,
|
||||||
name: ptz?.name || 'PTZ Camera',
|
name: ptz?.name || 'PTZ Camera',
|
||||||
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
||||||
// description: ptz?.isOperator
|
|
||||||
// ? 'Live camera turn active'
|
|
||||||
// : ptz?.queuedPosition
|
|
||||||
// ? `Queue position ${ptz.queuedPosition}`
|
|
||||||
// : 'Pan, tilt, and zoom camera',
|
|
||||||
}}
|
}}
|
||||||
queue={queue}
|
turn={ptz?.turn}
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
lookupUser={lookupUser}
|
|
||||||
canClick={canUse && !pending}
|
canClick={canUse && !pending}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
buttonLabel={actionLabel}
|
buttonLabel={actionLabel}
|
||||||
batteryLabel={ptz?.isOperator ? 'LIVE' : ptz?.queuedPosition ? `#${ptz.queuedPosition}` : '--'}
|
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
||||||
batteryClassName={ptz?.isOperator ? 'text-emerald-300' : ptz?.queuedPosition ? 'text-sky-300' : 'text-slate-400'}
|
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||||
timerLabel={timerLabel}
|
timerLabel={timerLabel}
|
||||||
onRequest={handleRequest}
|
onRequest={handleRequest}
|
||||||
showAction={canUse}
|
showAction={canUse}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// PTZ Live Video
|
// PTZ Live Video
|
||||||
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
|
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
|
||||||
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
|
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||||
import { RESTART_DELAY_MS } from '../RoverMediaPlayer/constants.js';
|
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||||
|
|
||||||
export const PTZ_CAMERA_ID = 'ptz-camera';
|
export const PTZ_CAMERA_ID = 'ptz-camera';
|
||||||
|
|
||||||
const PTZ_AUDIO_RETRY_MS = 1000;
|
const PTZ_AUDIO_RETRY_MS = 1000;
|
||||||
const TERMINAL_WHEP_STATES = new Set(['error', 'failed', 'disconnected', 'closed']);
|
|
||||||
const AUTHORIZATION_ERROR_RE = /not authorized/i;
|
const AUTHORIZATION_ERROR_RE = /not authorized/i;
|
||||||
|
|
||||||
function isAuthorizationError(error) {
|
function isAuthorizationError(error) {
|
||||||
@@ -25,14 +24,10 @@ export default function PtzLiveVideo({
|
|||||||
fallback = null,
|
fallback = null,
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const retryTimerRef = useRef(null);
|
|
||||||
const playTimerRef = useRef(null);
|
const playTimerRef = useRef(null);
|
||||||
const enabledRef = useRef(enabled);
|
|
||||||
const fallbackRef = useRef(false);
|
|
||||||
const playerGenerationRef = useRef(0);
|
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [restartToken, setRestartToken] = useState(0);
|
const { restartToken, scheduleRestart, cancelRestart } = useWhepRestart(enabled);
|
||||||
const sources = useVideoRequests(
|
const sources = useVideoRequests(
|
||||||
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
||||||
{ enabled, version: restartToken },
|
{ enabled, version: restartToken },
|
||||||
@@ -41,100 +36,34 @@ export default function PtzLiveVideo({
|
|||||||
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
|
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
enabledRef.current = enabled;
|
// An authorization denial switches to snapshots and cancels any queued retry.
|
||||||
}, [enabled]);
|
if (shouldUseFallback) cancelRestart();
|
||||||
|
}, [shouldUseFallback, cancelRestart]);
|
||||||
useEffect(() => {
|
|
||||||
fallbackRef.current = shouldUseFallback;
|
|
||||||
}, [shouldUseFallback]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (enabled && !shouldUseFallback) return undefined;
|
|
||||||
/*
|
|
||||||
A retry that was scheduled before the server denied live access should not
|
|
||||||
keep firing in snapshot mode. Clear it when live playback is no longer the
|
|
||||||
active display policy.
|
|
||||||
*/
|
|
||||||
if (retryTimerRef.current) {
|
|
||||||
clearTimeout(retryTimerRef.current);
|
|
||||||
retryTimerRef.current = null;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [enabled, shouldUseFallback]);
|
|
||||||
|
|
||||||
const scheduleRestart = useCallback(() => {
|
|
||||||
if (!enabledRef.current || fallbackRef.current) return;
|
|
||||||
/*
|
|
||||||
WHEP sessions are one-shot browser/server negotiations. When the camera
|
|
||||||
reboots, the old PeerConnection and token can look alive enough to keep a
|
|
||||||
black element on screen, but they are not useful anymore. Bumping this
|
|
||||||
token forces useVideoRequests to ask the server for a new MediaMTX auth
|
|
||||||
session before creating the next WhepPlayer.
|
|
||||||
*/
|
|
||||||
clearTimeout(retryTimerRef.current);
|
|
||||||
retryTimerRef.current = setTimeout(() => {
|
|
||||||
retryTimerRef.current = null;
|
|
||||||
setRestartToken(Date.now());
|
|
||||||
}, RESTART_DELAY_MS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
||||||
let active = true;
|
const video = videoRef.current;
|
||||||
const generation = playerGenerationRef.current + 1;
|
const stop = startWhepPlayback({
|
||||||
playerGenerationRef.current = generation;
|
|
||||||
const player = new WhepPlayer({
|
|
||||||
url: source.url,
|
url: source.url,
|
||||||
token: source.token,
|
token: source.token,
|
||||||
video: videoRef.current,
|
video: videoRef.current,
|
||||||
startMuted,
|
startMuted,
|
||||||
|
scheduleRestart,
|
||||||
onStatus: (nextStatus, info) => {
|
onStatus: (nextStatus, info) => {
|
||||||
/*
|
|
||||||
Old PeerConnection callbacks can arrive after React has already
|
|
||||||
cleaned up this effect for a newer token. Only the currently-owned
|
|
||||||
generation is allowed to update status or schedule another restart.
|
|
||||||
*/
|
|
||||||
if (!active || playerGenerationRef.current !== generation) return;
|
|
||||||
const normalized = String(nextStatus || '').toLowerCase();
|
|
||||||
setStatus(nextStatus || 'unknown');
|
setStatus(nextStatus || 'unknown');
|
||||||
setDetail(info || null);
|
setDetail(info || null);
|
||||||
if (TERMINAL_WHEP_STATES.has(normalized)) {
|
},
|
||||||
scheduleRestart();
|
onError: (err) => {
|
||||||
}
|
setStatus('error');
|
||||||
|
setDetail(err.message || 'WHEP start failed');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
player.start().catch((err) => {
|
|
||||||
if (!active || playerGenerationRef.current !== generation) return;
|
|
||||||
setStatus('error');
|
|
||||||
setDetail(err.message || 'WHEP start failed');
|
|
||||||
scheduleRestart();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
/*
|
|
||||||
Mark inactive before stop() because WhepPlayer reports "stopped" during
|
|
||||||
normal cleanup. Cleanup-driven stops should not immediately schedule the
|
|
||||||
next retry; only the replacement effect should own the new connection.
|
|
||||||
*/
|
|
||||||
active = false;
|
|
||||||
player.stop();
|
|
||||||
};
|
|
||||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
|
|
||||||
const generation = playerGenerationRef.current;
|
|
||||||
|
|
||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
if (playerGenerationRef.current !== generation) return;
|
|
||||||
setStatus('stopped');
|
setStatus('stopped');
|
||||||
setDetail('ended');
|
setDetail('ended');
|
||||||
scheduleRestart();
|
scheduleRestart();
|
||||||
};
|
};
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
if (playerGenerationRef.current !== generation) return;
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setDetail(video.error?.message || 'video element error');
|
setDetail(video.error?.message || 'video element error');
|
||||||
scheduleRestart();
|
scheduleRestart();
|
||||||
@@ -145,8 +74,9 @@ export default function PtzLiveVideo({
|
|||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('ended', handleEnded);
|
video.removeEventListener('ended', handleEnded);
|
||||||
video.removeEventListener('error', handleError);
|
video.removeEventListener('error', handleError);
|
||||||
|
stop();
|
||||||
};
|
};
|
||||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
|
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -178,7 +108,6 @@ export default function PtzLiveVideo({
|
|||||||
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
|
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
||||||
if (playTimerRef.current) clearInterval(playTimerRef.current);
|
if (playTimerRef.current) clearInterval(playTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -21,36 +21,20 @@ function roleColors(role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatQueueUserLabel(user, selfId) {
|
|
||||||
/*
|
|
||||||
Queue chips need to be readable even when a socket has no nickname yet.
|
|
||||||
Keeping the socket-prefix fallback here means rover queues and PTZ queues
|
|
||||||
degrade identically instead of each target inventing its own anonymous label.
|
|
||||||
*/
|
|
||||||
if (!user) return '';
|
|
||||||
const base = user.nickname || user.label || user.socketId?.slice(0, 6) || 'unknown';
|
|
||||||
if (user.socketId && user.socketId === selfId) {
|
|
||||||
return `${base} (you)`;
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QueueUserChips({
|
export function QueueUserChips({
|
||||||
targetId,
|
targetId,
|
||||||
queue = [],
|
turn,
|
||||||
currentId = null,
|
|
||||||
nextId = null,
|
|
||||||
selfId = null,
|
selfId = null,
|
||||||
lookupUser,
|
|
||||||
}) {
|
}) {
|
||||||
if (!queue.length) {
|
const { queue = [], currentId, nextId } = turn || {};
|
||||||
|
if (!queue?.length) {
|
||||||
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
|
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{queue.map((socketId, idx) => {
|
{queue.map((user, idx) => {
|
||||||
const user = lookupUser?.(socketId) || { socketId, nickname: null, role: null };
|
const { socketId } = user;
|
||||||
const isCurrent = socketId === currentId;
|
const isCurrent = socketId === currentId;
|
||||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||||
/*
|
/*
|
||||||
@@ -69,7 +53,7 @@ export function QueueUserChips({
|
|||||||
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
|
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
|
||||||
>
|
>
|
||||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
{formatQueueUserLabel(user, selfId)}
|
{user.name}{socketId === selfId ? ' (you)' : ''}
|
||||||
</span>
|
</span>
|
||||||
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||||
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||||
@@ -82,11 +66,8 @@ export function QueueUserChips({
|
|||||||
|
|
||||||
export default function QueueTargetRow({
|
export default function QueueTargetRow({
|
||||||
target,
|
target,
|
||||||
queue = [],
|
turn,
|
||||||
currentId = null,
|
|
||||||
nextId = null,
|
|
||||||
selfId = null,
|
selfId = null,
|
||||||
lookupUser,
|
|
||||||
canClick = false,
|
canClick = false,
|
||||||
pending = false,
|
pending = false,
|
||||||
locked = false,
|
locked = false,
|
||||||
@@ -164,11 +145,8 @@ export default function QueueTargetRow({
|
|||||||
</div>
|
</div>
|
||||||
<QueueUserChips
|
<QueueUserChips
|
||||||
targetId={targetId}
|
targetId={targetId}
|
||||||
queue={queue}
|
turn={turn}
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
lookupUser={lookupUser}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showAction ? (
|
{showAction ? (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user