Compare commits

...
Author SHA1 Message Date
legop3 008c90edf1 slop
Container image / image (push) Failing after 59s
2026-09-28 14:46:40 -04:00
legop3 eed88983ca slop 2026-09-28 14:18:35 -04:00
legop3 923c04736b slop 2026-09-28 12:52:20 -04:00
legop3 bdc3eb640d slop 2026-09-28 12:42:31 -04:00
legop3 01354c56e6 better defaults 2026-09-28 00:40:55 -04:00
legop3 0ff03a74a5 slop 2026-09-28 00:29:21 -04:00
legop3 73a78eef20 slop 2026-09-27 18:41:24 -04:00
legop3 dbcb7f6a7a rover VLA slop testing
Container image / image (push) Failing after 1m7s
2026-09-27 17:52:32 -04:00
legop3 aeeff6fe1b spectate pages have been upgraded
Container image / image (push) Failing after 55s
2026-09-23 13:54:41 -04:00
legop3 e750712a39 alert feed betterification
Container image / image (push) Failing after 1m0s
2026-09-20 16:22:04 -04:00
legop3 ff8b0c530e add link embedding in chat, update tailwind, update icons, tweak column layout. (8000 files changed because tailwind changed stuff in the update) 2026-09-20 15:41:45 -04:00
legop3 69c5bfe544 Merge pull request #28 from legop3/FEAT-live-discord-cnannel
Feat live discord cnannel
2026-09-20 15:05:57 -04:00
legop3 3ad95b376a add alertfeed to display, adjust alert feed opacities 2026-09-19 22:42:45 -04:00
legop3 bda740b08d add current thing too! and better visuals
Container image / image (push) Failing after 1m0s
2026-09-19 18:43:03 -04:00
legop3 3c8c5f5883 glorm 2026-09-19 17:03:59 -04:00
legop3 c35544e515 add battery voltage to /display so you can see voltages of batteries on the /display page by looking at /display to see the voltages of the batteries on the roombas in case you want to see the battery voltages!! yay!!!! 2026-09-19 17:00:23 -04:00
187 changed files with 9377 additions and 2439 deletions
+10
View File
@@ -97,6 +97,15 @@ RUN curl --fail --location --retry 3 \
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
FROM architecture-check AS learning-dependencies
# CPU-only learning dependencies are built once. Disabled installations never
# launch Python or install packages as a side effect of starting Node.
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
&& dnf clean all
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
RUN python3 -m venv /opt/rover-learning \
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt
FROM fedora:${FEDORA_VERSION} AS runtime
ARG FEDORA_VERSION
ARG TARGETARCH
@@ -148,6 +157,7 @@ COPY --from=webui-build /build/server/public ./public
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
COPY --from=packaged-tools /output/ /
COPY --from=learning-dependencies /opt/rover-learning /opt/rover-learning
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Finite, CPU-only rover policy compute benchmark. No rover/server connections.
Run from the repo root on Linux:
python3 -m venv ~/.venvs/rover-bench
~/.venvs/rover-bench/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
~/.venvs/rover-bench/bin/python perf/rover-model-benchmark.py
Quick run:
.../python perf/rover-model-benchmark.py --threads 4 --rovers 1 --seconds 2
Random inputs/weights measure compute cost ONLY, not learned driving ability.
This is a representative architecture, not a selected production policy.
Video decoding, network delay, dataset I/O, and simultaneous training/inference
are excluded. No packages are installed automatically and no files are written.
"""
import argparse
import json
import math
import os
import platform
import resource
import statistics
import subprocess
import sys
import time
def positive_int(value):
number = int(value)
if number < 1:
raise argparse.ArgumentTypeError("must be positive")
return number
def positive_seconds(value):
number = float(value)
if not math.isfinite(number) or number <= 0:
raise argparse.ArgumentTypeError("must be finite and positive")
return number
def arguments():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--threads", type=positive_int, nargs="+", default=[4, 8, 16])
parser.add_argument("--rovers", type=positive_int, nargs="+", default=[1, 2, 4])
parser.add_argument("--seconds", type=positive_seconds, default=10,
help="minimum measured seconds per case (default: 10)")
parser.add_argument("--frames", type=positive_int, default=4)
parser.add_argument("--height", type=positive_int, default=120)
parser.add_argument("--width", type=positive_int, default=160)
parser.add_argument("--batch-size", type=positive_int, default=16,
help="training batch size")
parser.add_argument("--hz", type=positive_int, default=10,
help="target decision frequency per rover")
parser.add_argument("--worker", choices=["inference", "training"],
help=argparse.SUPPRESS)
return parser.parse_args()
def worker(args):
import torch
from torch import nn
torch.set_num_threads(args.threads[0])
torch.set_num_interop_threads(1)
torch.manual_seed(42)
class Policy(nn.Module):
def __init__(self):
super().__init__()
# Preserve coarse spatial layout: global pooling alone would discard
# information about which side of the image contains an obstacle.
self.vision = nn.Sequential(
nn.Conv2d(3, 24, 5, stride=2, padding=2), nn.ReLU(),
nn.Conv2d(24, 48, 3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(48, 96, 3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(96, 128, 3, stride=2, padding=1), nn.ReLU(),
nn.AdaptiveAvgPool2d((3, 4)), nn.Flatten(),
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
)
# 32 sensor values + 32 previous-control/state values per frame.
self.history = nn.GRU(128 + 64, 128, batch_first=True)
self.continuous = nn.Linear(128, 8)
self.buttons = nn.Linear(128, 16)
def forward(self, images, state):
batch, frames, channels, height, width = images.shape
features = self.vision(images.reshape(-1, channels, height, width))
features = features.reshape(batch, frames, -1)
history, _ = self.history(torch.cat((features, state), dim=-1))
last = history[:, -1]
return self.continuous(last).tanh(), self.buttons(last)
model = Policy().cpu().float()
training = args.worker == "training"
model.train(training)
batch = args.batch_size if training else args.rovers[0]
# Keep several independent windows in RAM. Allocation/random generation is
# outside timing; full frame-history encoding IS included on every decision.
windows = [(
torch.rand(batch, args.frames, 3, args.height, args.width),
torch.rand(batch, args.frames, 64),
) for _ in range(4)]
targets = (torch.rand(batch, 8) * 2 - 1, torch.randint(0, 2, (batch, 16)).float())
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4) if training else None
def step(index):
if training:
optimizer.zero_grad(set_to_none=True)
continuous, buttons = model(*windows[index % len(windows)])
if training:
loss = (nn.functional.mse_loss(continuous, targets[0])
+ nn.functional.binary_cross_entropy_with_logits(buttons, targets[1]))
loss.backward()
optimizer.step()
context = torch.enable_grad if training else torch.inference_mode
with context():
for index in range(5):
step(index)
timings = []
cpu_start = time.process_time()
start = time.perf_counter()
# CPU execution is synchronous, so these durations include completion.
while time.perf_counter() - start < args.seconds or len(timings) < 10:
before = time.perf_counter()
step(len(timings))
timings.append(time.perf_counter() - before)
elapsed = time.perf_counter() - start
cpu_elapsed = time.process_time() - cpu_start
ordered = sorted(timings)
print(json.dumps({
"mode": args.worker, "threads": args.threads[0], "batch": batch,
"parameters": sum(p.numel() for p in model.parameters()),
"torch": torch.__version__, "iterations": len(timings),
"p50_ms": statistics.median(timings) * 1000,
"p95_ms": ordered[math.ceil(len(ordered) * .95) - 1] * 1000,
"max_ms": max(timings) * 1000,
"steps_s": len(timings) / elapsed,
"examples_s": len(timings) * batch / elapsed,
"cpu_cores": cpu_elapsed / elapsed,
"peak_mib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
}))
def main():
args = arguments()
if args.worker:
worker(args)
return
try:
import torch
except ImportError:
sys.exit("PyTorch is required. Run the three setup/run commands shown by --help.")
print(f"CPU-only FP32 | {platform.platform()} | Python {platform.python_version()}"
f" | PyTorch {torch.__version__}", flush=True)
print(f"Logical CPUs: {os.cpu_count()} | initial load averages: {os.getloadavg()}")
print(f"Input: {args.frames} RGB frames at {args.width}x{args.height},"
" 32 sensors + 32 controls/state per frame")
print("Output: 8 continuous controls + 16 button logits (placeholder dimensions)")
print("Synthetic in-memory data; full history re-encoded each decision.")
print("Excludes video decode/network/dataset I/O and concurrent training/inference.")
print("Each case has 5 warmups, then at least 10 iterations and the requested duration.")
print("CPU cores = process CPU seconds / wall seconds; peak MiB includes runtime/data.")
print("Multi-rover inference batches one independent history per rover per step.\n")
print("mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB", flush=True)
results = []
for mode in ("inference", "training"):
batches = args.rovers if mode == "inference" else [args.batch_size]
for threads in args.threads:
for batch in batches:
# Fresh processes isolate thread pools and peak memory per case.
command = [sys.executable, os.path.abspath(__file__), "--worker", mode,
"--threads", str(threads), "--rovers", str(batch),
"--batch-size", str(args.batch_size), "--seconds", str(args.seconds),
"--frames", str(args.frames), "--height", str(args.height),
"--width", str(args.width)]
process = subprocess.run(command, capture_output=True, text=True)
if process.returncode:
print(process.stderr, file=sys.stderr)
sys.exit(f"Failed: {mode}, threads={threads}, batch={batch}"
" (if killed for memory use, reduce --batch-size or --rovers).")
result = json.loads(process.stdout)
results.append(result)
print(f"{mode:10} {threads:7} {batch:5}"
f" {result['p50_ms']:7.1f} {result['p95_ms']:7.1f}"
f" {result['max_ms']:7.1f} {result['steps_s']:8.2f}"
f" {result['examples_s']:10.2f} {result['cpu_cores']:9.2f}"
f" {result['peak_mib']:8.0f}", flush=True)
print(f"\nModel parameters: {results[0]['parameters']:,}")
print(f"At {args.hz} Hz, each rover batch must finish every {1000 / args.hz:.1f} ms;")
print("p95 needs headroom for the excluded pipeline. This is not a deployment pass/fail.")
fastest = max((r for r in results if r['mode'] == 'training'), key=lambda r: r['examples_s'])
examples = 3600 * args.hz
minutes = examples / fastest['examples_s'] / 60
print(f"Best measured training: {fastest['examples_s']:.1f} windows/s at"
f" {fastest['threads']} threads.")
print(f"One pass over one rover-hour sampled at {args.hz} Hz ({examples:,} windows):"
f" about {minutes:.1f} compute-only minutes.")
print("Multiply by dataset hours and training passes; this does not predict convergence.")
print("Paste this complete output, along with the command/options used.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit("\nBenchmark interrupted.")
+788
View File
@@ -0,0 +1,788 @@
# Rover learning service implementation tracker
## Resume here
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and
admin-created permanent snapshots replace automatic permanent publications.
Status: policy format 2 now predicts continuous wheel speeds plus accessory events.
Copyable diagnostics and main-card wheel/action displays with a session threshold
slider are implemented. Older model files remain stored but cannot run in this format.
Recording, bounded dataset/training, permanent named models, live inference,
checked autonomous command submission, and the verified VIP card are coded.
Human commands do not stop or pause autonomy (explicit latest user requirement).
Focused local checks passed; actual recording/training, live autonomous behavior,
concurrent server performance, and browser appearance remain unverified.
The standalone CPU benchmark was run successfully on the actual server.
This document preserves the conversation's decisions across compactions and
usage cutoffs. Keep the checklist and handoff section current during authorized
implementation. Do not treat proposals or open questions as user decisions.
The user explicitly authorized feature implementation with: "okay cool. begin
implementation, use the md to track. IPI". Continue the agreed implementation
without requesting IPI again. Follow the current
AGENTS.md instructions: targeted investigation, minimal edits, no new tests unless
requested, proportional direct verification, and no development processes left
running. This development machine is not the actual rover server.
The user declined the manual server checklist and explicitly asked to continue.
Do not block further work or repeatedly ask them to do it. Preserve the unverified
status and have the reader reject unusable recordings automatically.
Latest increment: internal card gating, dock lead-in/tail capture, live prediction
diagnostics, ongoing retained-data sampling, and gated model publication are coded.
See the final handoff section for settings and the new training lineage.
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
the running browser. Do not invent successful driving results or block further
implementation on the previously declined manual checklist.
## Intended product
Learn from ordinary human rover use, then let a user select a saved model and
press Start to have it roam and operate the rover's controls. No destination or
structured route is required. Users should not need dedicated demonstration
sessions before collection becomes useful. Build the complete collection and
training pipeline; do not reduce the intended product to a single-route demo.
Chat is out of scope. The user explicitly dropped it to keep the model compact.
This is a learned control policy, not a language model generating instructions.
Learning useful behavior is an experimental objective, not a guaranteed result
of accumulating recordings. Autonomous performance must be observed; low training
loss or fast inference alone does not establish competent driving.
## Agreed requirements
- One self-contained server service, provisionally `roverLearningService`.
- Minimal integration elsewhere: configuration/startup, command recording hook,
checked command submission hook, and the necessary UI connection.
- The service owns its video recorder processes. Do not move this feature into
replay, rover management, turn management, or unrelated services.
- Configuration defaults to disabled. Disabled means no active feature runtime.
- Record every rover control input, video, and all available rover sensors.
- Model inputs use recent control history/current state, useful sensors, and
video. Model outputs can use the rover's supported operating controls, not just
wheel speeds. Available hardware/control capabilities must be accounted for.
- Record broadly; choosing training features must not discard raw source data
prematurely within the configured retention window.
- Retain a reasonable bounded rolling training dataset, with ongoing background
training. The model architecture/parameter count remains bounded as it learns.
- Periodically publish immutable named model versions. Two random words plus a
unique identifier/sequence is the proposed naming scheme.
- Keep every published model permanently. No automatic model pruning.
- Users can choose which compatible model controls their rover.
- Add the UI card at the top of the VIP tab, using the existing verified-feature
mechanism. The card is absent when the feature is disabled.
- Model control must use the same permissions and eligibility rules as manual
rover control. Verified feature access does not grant rover control.
- Favorites are client-side saved model IDs only. No server favorite/pin state,
endpoints, or retention behavior tied to favorites.
- All server information needed to populate the card arrives in one coherent
state object. Action commands remain separate from that state object.
Earlier suggestions to prune old model versions or retain only server-pinned
favorites are superseded. Rolling retention applies to training data, not the
published model catalog.
## Architecture and containment
Proposed internal ownership (not a requirement to scaffold empty directories):
```text
server/src/services/roverLearningService/
configuration.js Schema/defaults; no runtime side effects
index.js Small enable/disable lifecycle entry point
runtime.js Enabled runtime coordination
recording/ Commands, sensors, video processes, synchronization
dataset/ Rolling storage, training sampling, retention
training/ Training scheduling and checkpoint production
models/ Permanent catalog, names, metadata, publication
driving/ Inference sessions and action scheduling
workers/ Python model/training implementation
socketGateway.js Full card state and authorized user actions
```
Persistent video, datasets, checkpoints, and catalog metadata belong under the
application's existing data-path convention, not the service source directory.
The agreed root is `<data>/rover-learning/`, normally `/data/rover-learning/` in
the deployed container (`SERVER_DATA_DIR`), or `server/data/rover-learning/` with
the default development path. Intended layout:
```text
rover-learning/
recordings/ Rolling video/events/session metadata (implemented)
models/ Permanent UUID directories: weights.pt and model.json
training/ resume-v3.pt, schedule-v3.json, and current job.json
```
Training/model directories are created only when service and training are enabled.
Session/model manifests and a small scheduling ledger are sufficient at present;
the previously proposed catalog.sqlite is not needed or created.
Python compute and video processing must not block the Node event loop. Worker
dependencies, deployment packaging, and process shutdown must be included in the
implementation plan; installing Python packages at server startup is not assumed.
The outside command system should know only about optional observation and
checked submission. It should know nothing about model architecture, datasets,
training, model names, or video recorders.
### Existing integration findings (recheck before edits)
- `server/index.js` explicitly loads services.
- `server/src/configuration/definition.js` assembles service-owned configuration
fragments. `configuration/index.js` provides live reload handlers.
- `server/src/services/commandService/index.js` exports `issueCommand()` and
`commandEvents`. Existing `observation` events intentionally omit payloads;
they are analytics events and insufficient as training examples.
- `issueCommand()` is a low-level send function. Many authorization, turn,
cooldown, private-drive safety, and overcurrent checks occur in the browser
command handler before calling it. Direct autonomous calls must not bypass
those checks.
- `roverManager.managerEvents` already emits decoded `sensor` events and rover
lifecycle events. The sensor event is not necessarily every raw telemetry field;
inventory what is available before claiming complete sensor capture.
- `fleetReportService` demonstrates subscriptions and configuration lifecycle,
but its persistent storage/lifecycle is not automatically the desired disabled
behavior for this new service.
- `replayEngineV2` demonstrates FFmpeg capture and RTSP/TCP source handling. Reuse
applicable media conventions without making this feature depend on replay
being enabled or sharing replay's retention ownership.
### Command recording
Proposed optional observation contract includes timestamp, rover, operator/source,
requested command payload, dispatched payload, outcome, and command ID where
available. Requested and dispatched values can differ due to safety processing.
Distinguish dispatched/acknowledged commands from measured physical execution.
Record human, autonomous, and service-generated commands with distinct provenance.
Autonomous predictions are not automatically correct human training labels.
Include rejected/ignored outcomes where relevant; do not present them as executed
actions. Inventory control paths outside the common browser command handler so
camera/accessory controls are not silently omitted.
The hook only enqueues bounded work. Recording failures or disk pressure must not
block/fail manual command delivery. Preserve the existing payload-free analytics
event contract instead of putting detailed recordings into general logging.
Requested payload snapshots must precede any in-place command transformation.
### Checked command sending and ownership
Proposed implementation: extract a reusable checked submission path from the
current browser command handler, preserving existing manual behavior. Autonomy
submits on behalf of its initiating user's actual control context. Do not invent
an admin identity or send directly to the rover WebSocket.
Recheck permissions server-side for actions and continued control. Losing the
turn/assignment/connection or otherwise losing eligibility stops the autonomous
session. A safe stop must still be possible after ordinary permission is revoked.
Explicit user decision: incoming human commands do not stop or pause autonomy.
Both pass through the existing checked command path; subsequent model commands
can replace human commands. Stop remains explicit, with permission/disconnect,
stale input, and worker failure stops. Model-influenced sessions are excluded
from human demonstration recording, including manual inputs during autonomy.
Model actions describe operating controls, not arbitrary server maintenance.
Inventory reboot/update/raw commands and capability restrictions explicitly rather
than allowing unrestricted command dictionaries from model outputs.
### Disabled lifecycle
Only configuration metadata and a small lifecycle handler remain registered while
disabled, to support live enablement. Lazy-load runtime modules on enable.
When disabled there are no feature recording subscriptions, command observers,
video/Python workers, runtime timers, dataset scans, open feature databases,
created directories, inference/training, or feature socket handlers. The existing
config/feature mechanism hides the card without starting this runtime.
On disable: stop autonomous motion, detach observers, stop and await owned child
processes, flush/close storage appropriately, remove socket handlers/timers, and
release runtime state. Preserve all saved data/models. Serialize enable/disable
transitions so overlapping reloads cannot leave duplicate workers or listeners.
## Data and model design
The benchmark architecture is a representative workload, not a selected final
policy. It uses four RGB frames at 160x120, 32 sensor and 32 control/state values
per frame, a spatial CNN and GRU, eight continuous outputs, and sixteen button
logits. These counts are placeholders, not the actual rover control schema.
- Past controls/current state are inputs; the human's next action is a training
target. Avoid future-action leakage into model input.
- Maintain temporal coherence. Independent guesses can hesitate or oscillate;
multiple human intentions must not simply average into meaningless controls.
- Continuous controls and discrete button/toggle events require different output
semantics. Do not retrigger a one-shot action every inference tick.
- Include camera pose/state when available, and supported control capabilities.
- Preserve synchronized timestamps and sequence boundaries. Server receive time
does not prove which frame the remote driver saw. Exact displayed-frame timing
may need a small frontend timing hook; that is not yet designed or authorized as
a settled integration requirement.
- Human takeovers/corrections are valuable demonstrations. Do not indiscriminately
discard playful behavior: the goal includes human-like unstructured roaming.
- An action timeout/stale-video stop and existing hardware safety remain outside
the model's learned decision-making.
Training proposal: shuffle windows from retained sessions, including recent and
older examples. Publish after a configured amount of training work, potentially
with a minimum elapsed interval. Steps/examples are less ambiguous than epochs
over a continuously changing dataset. Continue training independently of active
inference; published models are immutable and do not change beneath a session.
Background work should be resource-limited and lower priority. Pausing after
sufficient reuse of unchanged data is proposed; continuous repeated passes are
not automatically useful. A small reserve of older valuable sessions was suggested
to reduce forgetting but is not a settled retention requirement.
Training readers must not lose files mid-batch to retention. Publish checkpoints
atomically so clients cannot select partially written models. Store sufficient
architecture, feature/action schema, normalization, and training metadata to load
each permanent model correctly or report it as incompatible.
## Model catalog and UI mechanics
Proposed model names: `curious-otter-0042`, `amber-finch-0043`. Names are display
labels; stable unique IDs are used for selection and local favorites. Record
creation time, architecture/schema identity, training progress, and dataset
provenance/summary. Never assume a newer model is better.
Card scope: model selection with local favorites, Start/Stop, current session
status, and compact training status. Reuse current VIP appearance and existing
settings persistence rather than inventing a new storage layer. UI implementation
locations have not been inspected yet.
Illustrative single state object (not a finalized protocol):
```js
{
available: true,
roverId,
control: { canStart, canStop, reason },
session: { status, modelId, startedBy, startedAt },
models: [{ id, name, createdAt, trainingSteps, compatible }],
training: { status, examplesProcessed, latestModelId }
}
```
Derive per-user permissions on the server and recheck on execution. State updates
should be coherent and event-driven/coalesced, not broadcast at camera frequency.
The permanent catalog can grow; avoid blocking command handling while assembling
or delivering card state. Any future pagination must preserve the user's single
card-state contract rather than silently introducing independent UI data feeds.
Selection semantics proposed earlier: stop before switching an active model,
reset temporal state, and resume only explicitly. Confirm exact behavior as part
of UI design. Client favorites do not affect server retention or permissions.
## Hardware and measured feasibility
Actual server: Fedora 43, two Xeon E5-2620 v4 processors, 16 physical cores / 32
threads, AVX2, about 125 GiB RAM with 118 GiB available in the initial sample.
The server only runs the rover server. Approximately 80% CPU idle was measured
under normal working load, not on an otherwise idle test machine.
The relevant benchmark is the custom vision/control workload, not the earlier
Qwen text benchmark. Server run: Python 3.14.7, PyTorch 2.14.0+cpu, FP32,
488,008 parameters, four RGB frames at 160x120. Training batch size 16.
| Mode | Threads | Rover/training batch | p50 ms | p95 ms | Max ms | Examples/s | CPU cores used | Peak MiB |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| Inference | 4 | 1 | 5.8 | 8.0 | 16.5 | 165.43 | 3.97 | 321 |
| Inference | 4 | 2 | 10.4 | 13.6 | 24.1 | 187.39 | 3.96 | 334 |
| Inference | 4 | 4 | 18.7 | 25.1 | 34.1 | 207.08 | 3.96 | 357 |
| Inference | 8 | 1 | 4.4 | 5.9 | 12.9 | 219.98 | 7.95 | 318 |
| Inference | 8 | 2 | 7.5 | 9.2 | 13.2 | 272.28 | 7.95 | 330 |
| Inference | 8 | 4 | 15.3 | 17.5 | 26.0 | 263.88 | 7.81 | 359 |
| Inference | 16 | 1 | 4.9 | 7.3 | 23.1 | 191.94 | 15.77 | 318 |
| Inference | 16 | 2 | 7.5 | 10.9 | 21.0 | 257.54 | 15.76 | 333 |
| Inference | 16 | 4 | 11.6 | 17.9 | 33.6 | 319.84 | 14.98 | 359 |
| Training | 4 | 16 | 210.7 | 227.4 | 245.5 | 75.36 | 3.79 | 655 |
| Training | 8 | 16 | 141.2 | 172.8 | 176.5 | 108.57 | 7.09 | 673 |
| Training | 16 | 16 | 121.6 | 228.9 | 263.4 | 112.37 | 13.29 | 669 |
Inference latency is per entire batch, not per rover within it. CPU measurements
are saturation runs, not paced 10 Hz operation. Eight threads is a promising
starting point, not a finalized resource allocation. At 10 Hz there are 36,000
training windows per recorded rover-hour; one pass takes about 5.5 compute-only
minutes at eight threads. Dataset hours and repeat passes multiply that cost.
Limits: synthetic data/random weights, no decoding/resizing/network/dataset I/O,
no concurrent inference and training, and no measured autonomous driving quality.
The target of 10 decisions/second is provisional. A larger model or higher image
resolution requires renewed measurement. These results do not mandate a GPU.
Approximate storage for this architecture: 1.9 MiB FP32 inference weights; 5.6 MiB
for weights plus Adam optimizer state, before metadata/serialization overhead.
One hundred inference versions are about 186 MiB. Dataset video dominates storage.
Published inference artifacts and resumable training state need not be identical.
## Open implementation decisions
- Exact operating-control catalog, continuous/event action encoding, capability
masking, selected sensors, model architecture/history, and update rate.
- Dataset disk budget, segment format, sampling strategy, session quality markers,
and whether to retain a reserved historical subset.
- Time alignment between video observed by the driver and command generation.
- Python packaging, IPC, process supervision, CPU limits, and worker failure policy.
- Checkpoint cadence/names, resume-state retention, evaluation metadata, and how
new versions become selectable. Do not add an unrequested admin approval flow.
- Exact ownership/takeover behavior, disconnect/turn-loss handling, model switch
behavior, and which user may stop an active session under existing permissions.
- Card fields and existing client persistence mechanism; current UI paths still
need targeted inspection.
## Implementation checklist
- [x] Discuss scope: autonomous roaming, full rover controls, no chat.
- [x] Add standalone CPU benchmark and verify its execution locally.
- [x] Obtain and interpret results from the actual server.
- [x] Record containment, default-disabled behavior, VIP placement, permissions,
permanent model retention, client favorites, and single card-state requirement.
- [x] Obtain IPI for feature implementation.
- [ ] Inspect remaining UI/control paths and settle concrete contracts.
- [x] Add service-owned config and dormant lifecycle integration.
- [x] Add optional detailed command observer and shared checked submission path.
- [ ] Implement timestamped recording, video worker ownership, and bounded dataset.
Code is implemented for common commands, raw/decoded rover sensor frames,
host stats, and existing rover state events. Live video/timing/retention
verification and full control-path inventory remain; do not mark complete yet.
- [x] Implement feature/action encoding and training worker with resource limits.
Runtime import/dependency checks passed; training on actual recordings is still
unverified. The current schema limits and next inference work are described below.
- [x] Implement atomic checkpoint publication and permanent model catalog.
Code complete; no actual-data published checkpoint has been produced locally.
- [x] Implement model inference, ownership, explicit stops, and worker cleanup.
Human input does not stop autonomy; live execution remains unverified.
- [x] Implement service socket gateway with complete per-user card state.
- [x] Implement verified VIP card at top, model selection, local favorites, controls,
and expandable diagnostics. Browser appearance remains unverified.
- [ ] Verify disabled startup and enable/disable cleanup directly.
- [ ] Verify manual-control behavior remains intact and autonomous permission loss
stops motion using an appropriate authorized runtime.
- [ ] Measure real video pipeline and concurrent training/inference on the server.
- [ ] Evaluate autonomous behavior with supervised rover runs before claiming
useful driving performance.
Verification must follow user policy: no new/expanded tests unless explicitly
requested; use existing small relevant checks or actual affected runtime. Do not
automatically run full suites/builds. Hardware/server checks must be handed to
the user when the development environment cannot perform them.
## Handoff log
### First implementation increment
Implemented files:
- `server/src/services/roverLearningService/configuration.js`: default disabled;
recording budget 20 GiB, minimum free space 2 GiB, session duration 300 seconds.
These are initial implementation defaults, adjustable in the existing admin
configuration editor, not measured final production sizing.
- `server/src/services/roverLearningService/index.js`: serialized lifecycle bridge;
lazy runtime require; no recording side effects while disabled.
- `server/src/services/roverLearningService/runtime.js`: enabled-only subscriptions,
recorder ownership, bounded startup/rotation event buffering, retention scheduling,
disconnect/rotation handling, and signal/disable cleanup.
- `server/src/services/roverLearningService/recording.js`: per-session metadata,
bounded NDJSON output, and FFmpeg RTSP/TCP video packet copy into Matroska.
No camera re-encoding, Python process, training, or autonomous commands yet.
- `server/src/services/roverLearningService/storage.js`: oldest-first recording
retention, free-space enforcement, and interrupted-session recovery.
- `server/src/services/commandService/recording.js`: optional failure-isolated
request/dispatch/result/ack observer with payload snapshots and IDs. Source is
`client` or `server`; browser automation is not falsely classified as proven
intentional human action. Async safety timers do not retain client attribution.
External changes are limited to startup, configuration definition, command-hook
wiring, and adding the new feature to an existing configuration test's expected
feature list (required by the feature addition). No new tests were created.
Raw sensor capture reads `lastSensor.raw` during the existing synchronous sensor
event; rover manager/sensor pipeline did not need modification.
Recording format:
```text
recordings/<unix-ms>-<uuid>/
session.json Schema version, rover/session identity, timing description,
start/end/reason/error, loss count, video exit information
events.ndjson Unix ms + process monotonic ns, kind, data
video.mkv Original video codec; receiver wall-clock PTS retained
```
Request/dispatch/ack IDs let later training join requested and applied commands.
The recorded `source` is provenance, not proof of a correct demonstration.
Queued events preserve their observation timestamp; file order around a new
session's snapshot is not necessarily chronological. Sort by timestamps when
constructing training windows. Rotations can have video gaps; do not train across
session boundaries as if footage were continuous.
Limits and follow-up:
- Retention is checked every 15 seconds and is a soft disk budget, not a filesystem
quota. Active files are closed before becoming eligible for pruning. Low free
space pauses capture. Models/training paths are never traversed by retention.
- Record only rovers with a connected human driver who currently passes the
existing control checks, including deterrence. Membership is required even for
admins so an admin login alone does not record unattended rovers. Preserve pauses
within an eligible human-control session. Driver/turn/mode/permission changes
reconcile recording immediately; telemetry buffering uses the same eligibility.
This supersedes the initial all-connected-rovers policy at the user's request.
Autonomous sessions will need explicit exclusion/provenance when implemented;
there are currently no autonomous controllers.
- Each event stream has a 2 MiB pending-write bound; startup/rotation buffering has
a shared 4 MiB bound. Session loss counters/capture-gap events identify overload.
- Video timestamps represent packet receipt at this server, not the frame a human
browser displayed. Actual FFmpeg output PTS still needs inspection on the server.
- Enabled recorders are stopped on disable and awaited; process-exit hooks kill
surviving children. Existing MediaMTX shutdown can exit the whole Node process
before async metadata flush finishes; next enable marks those sessions interrupted.
- Storage leases for concurrent training reads are pending with dataset sampling.
- Safe command submission extraction, autonomous provenance, model selection,
client favorites, and UI remain pending. Training/catalog code follows below.
Verification completed:
- `node --check` on new recording/lifecycle modules and modified command service:
passed (repeat only for subsequently changed modules as needed).
- `node --test server/src/configuration/configuration.test.js`: 14 passed, 1 failed.
The failing pre-existing default-values assertion omits
`$.discord.channels.liveStatus`; the current HEAD already defines it as empty.
New configuration validation, descriptions, normalization, and feature flags
passed. Leave the unrelated Discord assertion untouched.
- Direct disabled service startup with a disposable `SERVER_DATA_DIR` returned
`{"runtimeLoaded":false,"storageCreated":false}` and exited normally. Temporary
configuration storage was removed. No development service was left running.
- Local FFmpeg help confirms the RTSP transport and timeout options used.
- No full build, full lint, new tests, live server startup, or rover hardware run.
Optional server verification reference (user declined doing this checklist;
do not require it before continuing implementation):
1. Deploy normally, initially disabled; confirm no `rover-learning` directory or
learning FFmpeg workers are created by a fresh disabled startup.
2. Enable `roverLearning.enabled` through the existing admin configuration. An
online unattended rover must not produce recordings. Take human control and
confirm session files appear beneath the actual data mount; pauses should keep
recording. Release control/disconnect the last eligible driver and confirm
capture stops. Repeat across turn changes and mode/permission changes.
3. Drive, move the camera, and operate accessories. Check that event records contain
requested and dispatched payloads with matching IDs, sensor frames, and host stats.
4. For a completed session, inspect video with:
`ffprobe -v error -select_streams v:0 -read_intervals '%+#3' -show_entries packet=pts_time,dts_time -of json /actual/session/video.mkv`.
Compare packet timestamps (seconds) with event timestamps (milliseconds).
Report FFmpeg/session errors rather than assuming timestamp alignment is correct.
5. Disable: confirm owned FFmpeg processes exit, session metadata closes, no further
writes occur, and saved recordings remain. Re-enable to check clean restart.
6. Verify rotation and retention with an intentionally chosen small recording
budget, and confirm manual driving remains responsive during recording.
Actual server capture/timing and live cleanup remain unverified. These steps are
a reference for future runtime investigation, not a pending request to the user.
### Dataset/training/checkpoint increment
The user supplied IPI, then asked to continue after a rate-limit interruption.
Authorization remains in effect. Implemented:
- `workers/policy.py`: fixed-size spatial CNN/GRU with 64 action-shape slots and
up to 8 numeric output fields per shape. Four 160x120 RGB frames, spaced roughly
300 ms apart, with 32 selected sensors plus missing-value masks and previous
command values/ages/known flags. About 805k parameters (larger than the benchmark).
- Numeric controls are normalized with `2/pi * atan(value/scale)`; the model
produces per-shape event logits and numeric values for the next 100 ms.
Command types supported: drive, motors, servo, headlight, laser, horn, peripheral,
song, raw. Strings/bools/arrays remain exact template constants; variable-size
songs/raw OI payloads are discrete demonstrations, not generated arbitrary bytes.
Maintenance commands, audio/text and sensor-stream setup are not action targets.
All recordings remain intact until normal dataset retention, regardless of
whether the trainer can use their commands.
- The schema grows only into unused fixed slots and is saved with each model.
Overflow (64 shapes/8 numeric fields/4096-byte commands) skips the affected
recording with a reason instead of silently truncating controls. This is a
concrete initial encoding limit, not a promise to represent unlimited hardware
or arbitrary song libraries. Revisit it if real recordings exhaust capacity.
- `workers/dataset.py`: bounded event loading (64 MiB/session), PyAV decoding,
absolute timestamp checks, causal sensor/action history, accepted-client-command
targets, and masks for observed action slots. Actual measured wheel speeds are
among the selected sensor inputs. Missing/stale sensors, video gaps, clock shifts,
incomplete recordings, data loss, and ambiguous conflicting actions are excluded.
Server-generated control overrides are history/boundaries, not demonstrations.
- Validation is automatic and cannot establish what frame a browser displayed.
A session is only accepted after its video finishes decoding; samples from a
subsequently corrupt session are discarded. Commands/sensors at future times
do not enter observation history.
- `workers/train.py`: finite CPU jobs, lower OS priority, shuffled bounded reservoir
sampling, sparse-event classification plus masked numeric regression, gradient
clipping, finite-loss checks, and AdamW resume state. Never sends rover commands.
- `training.js`: one owned worker, recording leases acquired within serialized
maintenance, bounded stdout/stderr, interval and wall-clock limits, shutdown,
persistent per-recording visit/rejection ledger, and progress state for the
future card. Retention protects leased recordings. Disk pressure cancels the
training job before files become eligible for pruning again.
- Atomic `training/resume.pt` includes model and optimizer state, schema, cumulative
steps/examples, and publication progress. Permanent model directories are
atomically renamed into visibility with weights and metadata together.
- `models.js`: reads the permanent metadata catalog. Each model has a UUID, two-word
generated name plus UUID suffix, creation time, steps/examples, schema, source
session IDs, trained rover IDs, training loss, parameter count, weight hash/size,
and `evaluation: unevaluated`. No model pruning, favorites, or server pin state.
- Dockerfile packages a CPU-only Python venv at `/opt/rover-learning` during image
build. Pinned requirements: torch 2.14.0+cpu, NumPy 2.5.3, PyAV 18.1.0. No runtime
downloads/installers. A disabled service does not load or run these dependencies.
Training defaults beneath `roverLearning.training`:
| Setting | Default |
|---|---:|
| enabled (still gated by outer service enabled=false) | true |
| threads | 8 |
| batchSize | 16 |
| maxSamples / minimumSamples | 1024 / 128 |
| passesPerJob | 2 |
| minimumDrivingMinutes / newDrivingMinutesPerModel | 30 / 10 |
| maxStepsPerJob | 200 |
| checkpointEverySteps | 1000 |
| intervalSeconds | 60 |
| maxJobSeconds | 600 |
Defaults tuned to the user's dual E5-2620 v4 server benchmark: eight training
threads improved throughput substantially over four, while sixteen gave little
additional benefit. The larger sample pool and shorter interval are starting
choices for its available resources, not measured learning-quality improvements.
Inference remains four threads per rover. Existing explicit configuration values
override these defaults; this change does not rewrite deployed configuration.
At most four completed retained sessions are selected per job. Samples are spread
over each full video via reservoir sampling, not always taken from its beginning.
The prior three-visit limit is removed. Retained usable training sessions remain
eligible for repeated sampling; finite jobs still yield between runs.
Candidate publication happens at job boundaries after data, step, and held-out gates;
it does not automatically deploy a model. No all-no-action sample pool is trained.
Container rebuild is needed to include Python packages. For a non-container
deployment, install `workers/requirements.txt` into a dedicated venv and set
`training.python` to its Python path. The default path is container-specific.
Verification performed in this increment:
- Installed the actual pinned requirements in `/tmp/rover-bench-check-env`.
- Ran the production worker's `--check-dependencies`: PyTorch/PyAV/NumPy imports,
dataset imports, policy construction, and specification output succeeded.
- Four existing focused configuration tests passed (defaults, descriptions,
generated feature paths, normalization). The unrelated prior Discord default
assertion was not rerun or changed.
- Node syntax checks on scheduler/runtime/catalog passed.
- No new tests, fake recording fixtures, or throwaway test programs were created.
- No real recordings are available locally. Actual decoding/alignment, optimizer
execution against recordings, checkpoint publication/resume, and concurrent
server performance are not claimed verified. No full container build was run.
The following controller/UI increment implements those remaining code paths.
Real-data and live-hardware verification still remain before claiming usable autonomy. The initial model is an experimental imitation policy, not a demonstrated
competent driver or a final architecture choice.
When resuming after implementation begins, append concrete changed files, commands
run/results, unresolved failures, decisions approved by the user, and the next
bounded task here. Do not mark a checkbox complete merely because code exists;
record verification limits and outstanding server/hardware work explicitly.
### Controller and VIP card increment
- `commandService/index.js` exports the existing checked handler as `submitCommand`;
browser behavior remains in the same handler. Recording distinguishes model
requests from human requests without changing command payloads.
- `driving.js` owns per-rover CPU workers under the initiating live socket's
permissions. Explicit Stop, permission/connection loss, stale predictions, or
worker failure stop the session and send zero drive/motors and horn stop.
Human commands deliberately neither stop nor pause the model.
- `workers/infer.py` consumes live RTSP/TCP video plus sensor/command observations,
uses the training history format, validates weights/specification, resolves
competing command families, and latches discrete actions. It never writes to
the rover directly. Driving defaults: four CPU threads, 0.7 action threshold,
2000ms stale limit, with a 30-second worker startup allowance.
- `capabilities.js` matches recorded hardware control profiles against the target
rover. Recordings, resumable training state, and published manifests preserve
those profiles; models without a matching profile are not selectable for Start.
- `runtime.js` excludes autonomous sessions from human demonstrations, manages
controller shutdown, and supplies bounded recent activity and storage state.
- `socketGateway.js` emits one complete authorized card object every second to
subscribers, refreshes the permanent catalog every 15 seconds, and accepts
checked Start/Stop actions. No server-side favorites are stored.
- `VipRoverLearningCard.jsx` is at the top of the verified VIP tab, gated by the
service feature flag. It uses existing CardFrame, field/button/surface styles;
favorites use browser settings. Its expandable section includes storage,
recording sessions, dataset rejection reasons, training progress/resource
limits, model size/loss/control count, inference timing, last action, and events.
- Verification: focused ESLint on the new card and VipPanel passed; four existing
configuration tests passed. The live worker's actual `--help` entry point loaded
PyTorch/PyAV/NumPy successfully. Shared-handler diff reviewed with whitespace
ignored: extraction preserves the previous command handler logic.
- No real dataset, checkpoint, camera stream, or rover was available locally;
end-to-end inference/training and browser rendering remain unverified. No new
tests or background processes were added for verification, and no full build
or full test suite was run.
### Dock capture, live diagnostics, and publication correction
Authorized by IPI after the user reported idle dock recording, many premature
models, silent controllers, and training stopping after a few jobs.
- VipPanel now renders only `<VipRoverLearningCard />`; the card owns feature and
verification gating, rover selection context, and grid wrapper. The user's
pre-existing card edit was preserved.
- Human docked sessions maintain disposable packet-copy video segments and a
bounded five-second event buffer. Segments are keyframe-aligned (target two
seconds); old closed segments are pruned, with an extra edge segment retained
for decoding. Undocking promotes the buffer and sets trainingStartedAt to five
seconds earlier. After docking, recording closes after five seconds (250ms
polling granularity) and returns to buffering. Never-undocked buffers are
deleted on close/recovery and never offered to training. Disk retention still
applies. The reader handles chronological segments and filters the lead-in
boundary; no RTSP restart is needed to promote a dock buffer.
- The service still excludes autonomous sessions and still requires an eligible
human. Human input does not stop or pause autonomy.
- Expanded card diagnostics opt into complete volatile snapshots at 10Hz;
collapsed cards receive 1Hz. Predictions include all known command shapes,
scores, decoded controls, below-threshold/competing/latched reasons, and actual
submission results. "Predicting no new action" is separate from worker running.
- Removed roundsPerSession from configuration and scheduling. Completed usable
recordings remain eligible indefinitely within rolling retention. Jobs remain
finite, lower-priority, and separated by the configured interval. Waiting and
publication reasons are visible in the card.
- Default publication gates: 1000 optimizer steps, 30 distinct usable training
minutes, and 10 additional distinct minutes since the previous publication.
Usable minutes count enumerated aligned 100ms windows once per training session,
not repeated optimizer exposures; only a reservoir is used in each job.
- Entire sessions are deterministically partitioned 80/20 by session-ID hash.
Held-out sessions never supply gradient updates or new command vocabulary.
Evaluation requires at least 128 windows and 32 positive events, precision and
recall >=0.5 at the configured action threshold, and normalized numeric MSE
<=0.1 with numeric targets present. These are initial imitation gates, not proof
of autonomous competence. Evaluation uses the current job's held-out reservoir;
jobs with no usable held-out cohort cannot publish.
- Fresh `training/resume-v2.pt` prevents old training exposure contaminating the
new validation split. Existing published models and old resume.pt are preserved.
New checkpoints retain optimizer progress even when publication is blocked.
- Deployment config: migration 3 automatically removes the obsolete
`training.roundsPerSession` key from saved revisions before startup validation. Set `checkpointEverySteps: 1000`, `minimumDrivingMinutes: 30`,
and `newDrivingMinutesPerModel: 10`; saved values override changed defaults.
- Verification: four focused configuration tests passed, modified Node entry
points passed syntax checks, targeted VIP ESLint passed, and the production
training dependency entry point loaded the policy and segmented dataset reader.
No fixtures/test files, full builds, or background servers were created.
- Still unverified on this development machine: live RTSP segment timestamps and
dock transitions, actual optimizer/evaluation/publication runs on recordings,
browser appearance/live updates, and real autonomous behavior. Do not claim
these checks passed or that the existing event-based policy now drives well.
### Startup regression repair
The actual server crash reported CONFIG_VALIDATION_FAILED for the removed
roverLearning.training.roundsPerSession field. Added configuration database
migration 3 to remove only that obsolete key from all saved revisions before
startup validation. Other settings remain intact; normal default filling adds
new fields. No manual database/config edits are required. Existing focused
migration and normalization tests passed (2/2); production restart is unverified.
### Continuous wheel policy and copyable diagnostics (latest IPI)
Requirements: learn solely from ordinary use. Never require prescribed routes,
exercises, or demonstrations. Keep eight training threads until actual timings
justify changes. No artificial model-size increase. Manual controls still do not
stop or pause the controller. Keep card layout in the existing feature-card stack.
Implemented:
- Policy format 2 adds an independent two-value tanh wheel head (805,234 total
parameters), scaled to +/-500 mm/s. Accessory event/value heads remain separate;
drive command shapes remain causal history inputs but are masked as event targets.
- Dataset reconstructs accepted human wheel intent at frame time +100ms, using
dispatched wheel values, explicit zero stops, and the recorded hardware limit
when available. Inspection of roverd dispatch found persistent wheel commands,
not a short wheel-command expiry timer. Permission boundaries, server overrides,
failed/ambiguous drive commands and raw/mode/system changes invalidate wheel
intent until another accepted human drive. Uncertain windows are excluded.
Raw OI controls are consequently not reliable trainable events in this revision;
their effects require dedicated decoding before relaxing that exclusion.
- Four bounded training reservoirs preserve stopped, forward, reverse and turn
examples without requiring humans to behave differently. Held-out reservoirs
remain uniformly sampled and entire sessions retain deterministic partitioning.
- Loss reports separate wheel, accessory-event and accessory-value components.
Held-out wheel MAE is compared with stopped-wheel and previous-command baselines,
with separate error on actual wheel changes. Publication needs >=128 evaluation
windows, >=32 moving and >=32 changed windows, improvement over stopped and
changed-window persistence baselines, and overall error no worse than persistence.
Accessory precision/recall gates remain when events exist; absent accessory
events must not produce false positives. These remain experimental imitation
criteria, not proof of autonomous competence.
- Existing footage can be reused. New learning state is resume-v3.pt and
schedule-v3.json, preventing old-shape weights or stale rejection ledgers from
being reused. Existing published files remain untouched and visibly marked as
older format/incompatible. New compatible models are required to start driving.
- Live inference emits a wheel command every decision, independent of the accessory
threshold. Server clamps wheel values to hardware limits through the unchanged
shared authorization path. Start/Stop and manual-input behavior are preserved.
- Main card shows centered signed wheel bars, accessory score/status bars and a
session-local threshold slider (0.05..0.99). Only users with current rover control
can adjust it. Worker receives settings over its existing stdin channel; desired
and applied thresholds are separately observable. Active controllers get 10Hz
snapshots even when details are collapsed.
- Copy diagnostics exports compact reportVersion=1 JSON from the same complete
card object. It includes application version, service-source SHA256 fingerprint,
policy version, settings, host load/memory, dataset timing/availability counters,
separate losses, baseline evaluation, decode/train/evaluation/checkpoint timing,
CPU seconds and peak RSS, publication blockers, 20 job summaries and up to 30s
of controller history sampled at 2Hz (top eight action scores per entry).
- Reports use an explicit allowlist: no socket IDs, nicknames, rover IDs, filesystem
paths, raw video, credential-bearing URLs or raw command templates. Errors are
categorized rather than copying potentially sensitive arbitrary exception text.
Browser video latency and actual physical command execution are explicitly
unavailable. Diagnostics payloads are sent only while details are expanded;
active action visualization remains live in the main card.
- Clipboard failure exposes a selectable compact report instead of silently failing.
- No recorded dataset exists under this checkout's server/data/rover-learning.
Production worker dependency/entry-point checks succeeded; policy construction
reports 805,234 parameters. Targeted card ESLint and Node syntax checks passed.
Actual optimizer execution, held-out results, RTSP inference, clipboard/slider
behavior and browser appearance remain unverified here. No new tests, fake data,
or background development processes were created.
Next: use real diagnostic reports to assess timing, sample rejection, target
coverage and baseline performance. Do not infer competence from lower training
loss or increase architecture size without that evidence. If training was disabled
through the configuration UI, the operator must re-enable it to train this policy.
### Ongoing learner and administrator snapshots
Authorized by the latest IPI. Training continues from the same resume-v3.pt;
no new training reset or configuration migration is required.
- Each completed training job exports immutable weights/metadata beneath
training/checkpoints/<uuid>. A serialized Node catalog atomically updates
index.json with candidate, latest, and previous pointers after worker exit.
- Existing data/step/evaluation gates govern Latest promotion. Previous retains
the former Latest. Candidate tracks the latest completed learner even when
those gates fail. No automatic permanent named-model creation remains.
- The model picker exposes Latest and Previous alongside existing permanent
snapshots. Running sessions acquire a checkpoint lease and keep their original
weights; promotion does not reload them. Retention removes superseded automatic
checkpoints, with leased ones removed after their running session closes.
- Admin-only Save learner snapshot copies the candidate atomically to a new
permanent two-word/UUID model directory, carrying its evaluation and source
checkpoint ID. Candidates without a passing evaluation are labeled experimental.
Non-admin requests are rejected server-side. Normal rover-control permissions
still apply when starting any model. Existing models/favorites remain intact.
- Source fingerprints and controller diagnostics include the pinned checkpoint ID.
Configuration field names are unchanged; descriptions now refer to Latest
promotion rather than permanent publication. Permanent snapshots are never pruned.
- Focused Node syntax and Python worker entry-point checks passed. Targeted card
lint is run for the UI change. End-to-end training/promotion, snapshot copying,
active-session pinning and browser behavior remain unverified without live data.
+1
View File
@@ -68,6 +68,7 @@ require('./src/services/batteryManager');
// subscriptions see fully decoded state without becoming an initialization
// dependency of either control path.
require('./src/services/fleetReportService');
require('./src/services/roverLearningService');
require('./src/services/replayEngineV2');
// Replay delivery is a core service. It must subscribe before the optional
// Discord feature so web requests always have a local delivery path.
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
@@ -1 +0,0 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card-header{top:var(--configuration-sticky-top, 0px)}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key-label{font-size:1rem;line-height:1.5rem;font-weight:600;line-height:1.375;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
import{r as g,R as a}from"./index-dJH5nUWV.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-BBzOMZjy.js.map
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-n0JxE1Mv.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-CaWv689i.js.map
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-dJH5nUWV.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-Ya006OtX.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
+2
View File
@@ -0,0 +1,2 @@
function u(i){return class extends i{_playedRanges=[];_currentPlayedRange=null;_RANGE_EPSILON=.5;_seeking=!1;connectedCallback(){super.connectedCallback&&super.connectedCallback(),this.addEventListener("play",()=>this.onPlaybackStart({time:this.currentTime})),this.addEventListener("pause",()=>this.onPlaybackStop({time:this.currentTime})),this.addEventListener("ended",()=>this.onPlaybackStop({time:this.currentTime})),this.addEventListener("seeking",()=>this.onSeeking()),this.addEventListener("seeked",()=>this.onSeeked({time:this.currentTime}))}onPlaybackStart(e={}){this._seeking=!1;const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._currentPlayedRange||(this._currentPlayedRange={start:n,end:n})}onSeeking(){this._seeking=!0,this._commitCurrentRange()}onSeeked(e={}){this._seeking=!1;const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._currentPlayedRange={start:n,end:n}}onPlaybackStop(e={}){const{time:t}=e,n=typeof t=="number"?t:this.currentTime;this._commitCurrentRange(n)}_commitCurrentRange(e){if(!this._currentPlayedRange)return;typeof e=="number"&&(this._currentPlayedRange.end=e);const{start:t,end:n}=this._currentPlayedRange;this._currentPlayedRange=null,this.addPlayedRange(t,n)}addPlayedRange(e,t){if(e>=t)return;const n=this._RANGE_EPSILON,c=[...this._playedRanges,{start:e,end:t}];c.sort((a,s)=>a.start-s.start);const r=[];for(const a of c){if(!r.length){r.push({...a});continue}const s=r[r.length-1];a.start<=s.end+n?(s.start=Math.min(s.start,a.start),s.end=Math.max(s.end,a.end)):r.push({...a})}this._playedRanges=r}get played(){const e=this.currentTime;return!this.paused&&!this._currentPlayedRange&&typeof e=="number"&&(this._currentPlayedRange={start:e,end:e}),this._currentPlayedRange&&typeof e=="number"&&(e>this._currentPlayedRange.end&&(this._currentPlayedRange.end=e),this.addPlayedRange(this._currentPlayedRange.start,this._currentPlayedRange.end)),this._playedRanges.length?l(this._playedRanges.map(t=>[t.start,t.end])):l([[0,0]])}}}function l(i){return Object.defineProperties(i,{start:{value:d=>i[d][0]},end:{value:d=>i[d][1]}}),i}export{u as M};
//# sourceMappingURL=index-B4nclofW.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import{_ as N,R as A}from"./index-dJH5nUWV.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-C61Q8sy9.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
+35
View File
@@ -0,0 +1,35 @@
import{R as W}from"./index-dJH5nUWV.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-CVBBC_mw.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-n0JxE1Mv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xCRVLGjq.css">
<script type="module" crossorigin src="/assets/index-dJH5nUWV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DOmNOCX4.css">
</head>
<body>
<div id="root"></div>
@@ -184,6 +184,7 @@ test('service definitions generate public feature paths without a separate regis
{ key: 'discord', path: ['discord', 'enabled'] },
{ key: 'socials', path: ['socials', 'enabled'] },
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
{ key: 'roverLearning', path: ['roverLearning', 'enabled'] },
]);
});
+2
View File
@@ -22,6 +22,7 @@ const barcodeScanner = require('../services/barcodeScannerService/configuration'
const commands = require('../services/operatorCommandService/configuration');
const discord = require('../services/discordBotService/configuration');
const fleetReports = require('../services/fleetReportService/configuration');
const roverLearning = require('../services/roverLearningService/configuration');
/*
Object property order is preserved by JSON serialization and JSON Schema
@@ -55,6 +56,7 @@ const definitions = [
sessionConfiguration.socials,
sessionConfiguration.driverAd,
fleetReports,
roverLearning,
];
const defaultConfig = Object.fromEntries(
+12
View File
@@ -72,6 +72,18 @@ const migrations = [
});
},
},
{
version: 3,
run(db) {
// Migrate historical revisions too, so rolling back settings cannot
// restore the removed training-visit limit and break startup again.
db.prepare(`
UPDATE configuration_revisions
SET config_json = json_remove(config_json, '$.roverLearning.training.roundsPerSession')
WHERE json_type(config_json, '$.roverLearning.training.roundsPerSession') IS NOT NULL
`).run();
},
},
];
function applySchemaMigrations(db) {
+137 -130
View File
@@ -11,6 +11,7 @@ const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService');
const overcurrentProtectionService = require('../overcurrentProtectionService');
const { subscribeCommandRecording, observeCommand, recordCommandRequest } = require('./recording');
// Command observations are intentionally separate from the global event bus.
// Drive and motor commands can run at control-loop frequency, and publishing
@@ -96,6 +97,7 @@ function issueCommand(roverId, payload) {
const normalizedPayload = normalizeOutboundCommandPayload(payload);
const message = { ...normalizedPayload, id };
record.ws.send(JSON.stringify(message));
observeCommand({ phase: 'dispatched', roverId, commandId: id, command: normalizedPayload });
pendingCommands.set(id, { roverId, ts: Date.now(), type: normalizedPayload.type });
logger.info('Issued command', roverId, normalizedPayload.type, id);
return id;
@@ -130,6 +132,7 @@ function handleAck(msg) {
const pending = pendingCommands.get(msg.id);
if (!pending) return;
pendingCommands.delete(msg.id);
observeCommand({ phase: 'acknowledged', roverId: pending.roverId, commandId: msg.id, acknowledgement: msg });
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
io.emit('commandAck', {
roverId: pending.roverId,
@@ -255,145 +258,149 @@ module.exports = {
getRecentDriveActivity,
setDriveCooldown,
commandEvents,
subscribeCommandRecording,
submitCommand,
};
io.on('connection', (socket) => {
function handleCommand({ roverId, type, data } = {}, cb) {
const reply = typeof cb === 'function' ? cb : () => {};
try {
if (!roverId) {
throw new Error('roverId required');
// Shared authorization/safety boundary for browser and service-owned control.
function submitCommand(socket, { roverId, type, data } = {}, cb) {
const reply = typeof cb === 'function' ? cb : () => {};
try {
if (!roverId) {
throw new Error('roverId required');
}
if (!type) {
throw new Error('type required');
}
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
let payload = data ? { ...data } : {};
if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
return;
}
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
logger.info('Ignoring laser command while room lights are locked on', {
socketId: socket.id,
roverId,
action: getLaserAction(payload),
});
reply({ ignored: true, reason: 'roomLightsLockedOn' });
return;
}
const isRebootCommand = type === 'reboot';
const isUpdateCommand = type === 'update';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
const isAdminSocket = isAdmin(socket);
if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized');
}
/*
Both structured song commands and raw Open Interface song payloads
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
and any future browser beeper without affecting unrelated driving.
*/
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
throw new Error('Muted');
}
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system
// maintenance privileges.
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
throw new Error('Not authorized');
}
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect) {
if (!isAdminSocket) {
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
if (safeDrive) {
payload.driveDirect = safeDrive;
}
}
if (!type) {
throw new Error('type required');
}
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
let payload = data ? { ...data } : {};
if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
return;
const left = Number(payload?.driveDirect?.left);
const right = Number(payload?.driveDirect?.right);
const speed = Math.max(Math.abs(left), Math.abs(right));
if (!isAdminSocket) {
const blockedUntil = driveCooldowns.get(roverId);
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
const reason = isLockdownAdmin(socket)
? 'Drive blocked: cooldown'
: 'Drive blocked: safety cooldown';
throw new Error(reason);
}
}
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
logger.info('Ignoring laser command while room lights are locked on', {
if (speed > 0) {
let direction = 'turn';
if (left > 0 && right > 0) direction = 'forward';
if (left < 0 && right < 0) direction = 'backward';
lastDriveActivity.set(roverId, {
ts: Date.now(),
socketId: socket.id,
roverId,
action: getLaserAction(payload),
});
reply({ ignored: true, reason: 'roomLightsLockedOn' });
return;
}
const isRebootCommand = type === 'reboot';
const isUpdateCommand = type === 'update';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
const isAdminSocket = isAdmin(socket);
if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized');
}
/*
Both structured song commands and raw Open Interface song payloads
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
and any future browser beeper without affecting unrelated driving.
*/
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
throw new Error('Muted');
}
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system
// maintenance privileges.
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
throw new Error('Not authorized');
}
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect) {
if (!isAdminSocket) {
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
if (safeDrive) {
payload.driveDirect = safeDrive;
}
}
const left = Number(payload?.driveDirect?.left);
const right = Number(payload?.driveDirect?.right);
const speed = Math.max(Math.abs(left), Math.abs(right));
if (!isAdminSocket) {
const blockedUntil = driveCooldowns.get(roverId);
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
const reason = isLockdownAdmin(socket)
? 'Drive blocked: cooldown'
: 'Drive blocked: safety cooldown';
throw new Error(reason);
}
}
if (speed > 0) {
let direction = 'turn';
if (left > 0 && right > 0) direction = 'forward';
if (left < 0 && right < 0) direction = 'backward';
lastDriveActivity.set(roverId, {
ts: Date.now(),
socketId: socket.id,
direction,
speed,
isAdmin: isAdminSocket,
});
}
}
if (type === 'drive' || type === 'motors') {
/*
Role is supplied at the command boundary because telemetry does not
identify the operator who produced the active motor intent. Admin and
lockdown commands therefore enter the service explicitly bypassed;
they are recorded for status visibility but are never scaled, blocked,
or countermanded by a later sensor frame.
*/
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
bypassed: isAdminSocket,
direction,
speed,
isAdmin: isAdminSocket,
});
}
const id = issueCommand(roverId, { type, ...payload });
commandEvents.emit('observation', {
ts: Date.now(),
roverId: String(roverId),
type,
commandId: id,
outcome: 'issued',
socketId: socket.id,
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
// commands can carry arbitrary content. Their structured type/outcome
// supplies analytics without accidentally persisting secret material.
});
logger.info('Queued command', socket.id, roverId, type);
if (shouldRecordTurnActivity(type, payload)) {
try {
const { recordActivity } = require('../turnService');
recordActivity(roverId, socket.id);
} catch (err) {
// Activity recording is best-effort because command delivery should not fail if turn bookkeeping has a transient issue.
}
}
reply({ id });
} catch (err) {
logger.warn('Command rejected', socket.id, err.message);
commandEvents.emit('observation', {
ts: Date.now(),
roverId: roverId ? String(roverId) : null,
type: type || 'unknown',
outcome: 'rejected',
socketId: socket.id,
error: err.message,
});
reply({ error: err.message });
}
}
socket.on('command', handleCommand);
socket.on('command:issue', handleCommand);
if (type === 'drive' || type === 'motors') {
/*
Role is supplied at the command boundary because telemetry does not
identify the operator who produced the active motor intent. Admin and
lockdown commands therefore enter the service explicitly bypassed;
they are recorded for status visibility but are never scaled, blocked,
or countermanded by a later sensor frame.
*/
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
bypassed: isAdminSocket,
});
}
const id = issueCommand(roverId, { type, ...payload });
commandEvents.emit('observation', {
ts: Date.now(),
roverId: String(roverId),
type,
commandId: id,
outcome: 'issued',
socketId: socket.id,
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
// commands can carry arbitrary content. Their structured type/outcome
// supplies analytics without accidentally persisting secret material.
});
logger.info('Queued command', socket.id, roverId, type);
if (shouldRecordTurnActivity(type, payload)) {
try {
const { recordActivity } = require('../turnService');
recordActivity(roverId, socket.id);
} catch (err) {
// Activity recording is best-effort because command delivery should not fail if turn bookkeeping has a transient issue.
}
}
reply({ id });
} catch (err) {
logger.warn('Command rejected', socket.id, err.message);
commandEvents.emit('observation', {
ts: Date.now(),
roverId: roverId ? String(roverId) : null,
type: type || 'unknown',
outcome: 'rejected',
socketId: socket.id,
error: err.message,
});
reply({ error: err.message });
}
}
io.on('connection', (socket) => {
const recordedCommand = (request, reply) => recordCommandRequest(socket, request, reply, (message, cb) => submitCommand(socket, message, cb));
socket.on('command', recordedCommand);
socket.on('command:issue', recordedCommand);
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
@@ -0,0 +1,55 @@
// Optional detailed command observation, separate from payload-free analytics.
// Subscribers must only enqueue work; recorder failures cannot fail a command.
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');
const observers = new Set();
const requests = new AsyncLocalStorage();
function subscribeCommandRecording(observer) {
observers.add(observer);
return () => observers.delete(observer);
}
function observeCommand(event) {
if (!observers.size) return;
const scope = requests.getStore();
const context = scope?.active ? scope.context : null;
for (const observer of observers) {
try {
// Snapshot before safety code or a caller mutates a nested payload. Each
// subscriber receives its own copy and cannot change command delivery.
observer(structuredClone({
ts: Date.now(),
monotonicNs: process.hrtime.bigint().toString(),
source: context ? 'client' : 'server',
...context,
...event,
}));
} catch {
// Recording is deliberately best-effort at this boundary. The owning
// recorder reports its storage health independently of driver replies.
}
}
}
function recordCommandRequest(socket, request, reply, execute, source = 'client') {
if (!observers.size) return execute(request, reply);
const context = { requestId: randomUUID(), socketId: socket.id, userId: socket.data?.userId || null, source };
const scope = { active: true, context };
return requests.run(scope, () => {
try {
observeCommand({ phase: 'requested', roverId: request?.roverId, command: request });
return execute(request, (result) => {
observeCommand({ phase: 'result', roverId: request?.roverId, result });
if (typeof reply === 'function') reply(result);
});
} finally {
// The command handler is synchronous. A safety timer created inside it
// inherits async context but its later commands are server actions.
scope.active = false;
}
});
}
module.exports = { subscribeCommandRecording, observeCommand, recordCommandRequest };
@@ -0,0 +1,15 @@
// Match actuator configuration, not a rover's display name or changing telemetry.
const { isDeepStrictEqual } = require('node:util');
function controlProfile(meta = {}) {
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
.map((key) => [key, meta?.[key] ?? null]));
}
function compatible(model, record) {
if (model?.specification?.version !== 2 || !record) return false;
const profile = controlProfile(record.meta);
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
}
module.exports = { controlProfile, compatible };
@@ -0,0 +1,47 @@
// Metadata only: loading the configuration must never start the learning runtime.
const { strictObject, boolean, integer, number, string } = require('../../configuration/schemaHelpers');
module.exports = {
key: 'roverLearning',
feature: true,
defaultValue: {
enabled: false,
recording: { maxGiB: 20, minimumFreeGiB: 2, sessionSeconds: 300 },
driving: { threads: 4, actionThreshold: 0.7, staleMs: 2000 },
training: {
enabled: true, python: '/opt/rover-learning/bin/python', threads: 8,
batchSize: 16, maxSamples: 1024, minimumSamples: 128, passesPerJob: 2,
maxStepsPerJob: 200, checkpointEverySteps: 1000,
minimumDrivingMinutes: 30, newDrivingMinutesPerModel: 10,
intervalSeconds: 60, maxJobSeconds: 600,
},
},
schema: strictObject({
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
driving: strictObject({
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum predicted action probability for issuing a new control event.' }),
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
recording: strictObject({
maxGiB: number({ minimum: 0.1, maximum: 100000, description: 'Rolling recording budget in GiB. Checked every 15 seconds; active files can temporarily exceed it. Published models are never pruned.' }),
minimumFreeGiB: number({ minimum: 0.1, maximum: 100000, description: 'Pause recording below this amount of available filesystem space, in GiB.' }),
sessionSeconds: integer({ minimum: 30, maximum: 3600, description: 'Rotate video and telemetry sessions after this many seconds so completed sessions can be pruned.' }),
}, { description: 'Bounded recording storage. Limits never delete published models.', required: ['maxGiB', 'minimumFreeGiB', 'sessionSeconds'] }),
training: strictObject({
enabled: boolean({ description: 'Train periodically from completed usable human recordings while the service is enabled.' }),
python: string({ minLength: 1, description: 'Python executable with the service requirements installed. The container supplies /opt/rover-learning/bin/python.' }),
threads: integer({ minimum: 1, maximum: 32, description: 'CPU threads for the lower-priority training process.' }),
batchSize: integer({ minimum: 1, maximum: 64, description: 'Training windows per optimizer step.' }),
maxSamples: integer({ minimum: 128, maximum: 2048, description: 'Maximum decoded windows retained in RAM per job. Each contains four 160x120 RGB frames.' }),
minimumSamples: integer({ minimum: 1, maximum: 128, description: 'Usable sampled windows required before training.' }),
passesPerJob: integer({ minimum: 1, maximum: 10, description: 'Shuffled passes over the sampled windows per job.' }),
minimumDrivingMinutes: number({ minimum: 1, maximum: 100000, description: 'Minimum distinct usable training footage in minutes before promoting Latest; repeated passes do not count.' }),
newDrivingMinutesPerModel: number({ minimum: 1, maximum: 100000, description: 'Additional distinct usable footage required between Latest promotions.' }),
maxStepsPerJob: integer({ minimum: 1, maximum: 10000, description: 'Optimizer step cap per finite training job.' }),
checkpointEverySteps: integer({ minimum: 1, maximum: 100000, description: 'Accumulated training steps between gated Latest promotions. Permanent named snapshots are created by administrators.' }),
intervalSeconds: integer({ minimum: 30, maximum: 86400, description: 'Minimum seconds between completed training jobs.' }),
maxJobSeconds: integer({ minimum: 30, maximum: 3600, description: 'Wall-clock budget including video decoding; slow/stuck workers are stopped.' }),
}, { description: 'Ongoing CPU learning, sampling, and gated Latest checkpoint updates.', required: ['enabled', 'python', 'threads', 'batchSize', 'maxSamples', 'minimumSamples', 'passesPerJob', 'minimumDrivingMinutes', 'newDrivingMinutesPerModel', 'maxStepsPerJob', 'checkpointEverySteps', 'intervalSeconds', 'maxJobSeconds'] }),
}, { description: 'Optional rover recording and learning service.', required: ['enabled', 'recording', 'training', 'driving'] }),
};
@@ -0,0 +1,60 @@
// Export an explicit allowlist, never the raw socket/session/configuration object.
const os = require('node:os');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const applicationVersion = require('../../../package.json').version;
const hash = createHash('sha256');
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py',
'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) {
hash.update(fs.readFileSync(path.join(__dirname, file)));
}
const implementationSha256 = hash.digest('hex');
function errorCategory(value) {
if (!value) return null;
const text = String(value).toLowerCase();
for (const [pattern, category] of [
[/timestamp|pts|clock|aligned/, 'time_alignment'], [/sensor/, 'sensors'],
[/video|decode|ffmpeg|rtsp/, 'video'], [/permission|turn|control permission/, 'permission'],
[/schema|shape|version/, 'model_schema'], [/space|disk|enospc/, 'storage'],
[/sample|window|command/, 'dataset_or_command'], [/worker|exited|process/, 'worker'],
]) if (pattern.test(text)) return category;
return 'unclassified';
}
function report(state, controller) {
const training = state.training || {};
const metrics = training.diagnostics || {};
const session = state.session;
return {
reportVersion: 1, generatedAtUnixMs: state.updatedAt,
software: { applicationVersion, implementationSha256, policyVersion: 2, node: process.version,
dependencies: metrics.dependencies || null },
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
settings: state.limits,
recording: { paused: state.recording.paused, bytes: state.recording.bytes,
freeBytes: state.recording.freeBytes, pendingBytes: state.recording.pendingBytes,
droppedPending: state.recording.droppedPending,
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
status: item.video, droppedEvents: item.droppedEvents, errorCategory: errorCategory(item.error) })) },
training: { status: training.status, steps: training.steps, examples: training.examples,
loss: training.loss, losses: training.losses || null, publication: training.publication || null,
evaluation: training.evaluation || null, dataset: metrics.dataset || training.dataset || null,
resources: { decodeSeconds: metrics.decodeSeconds ?? null, trainingSeconds: metrics.trainingSeconds ?? null,
evaluationSeconds: metrics.evaluationSeconds ?? null, checkpointSeconds: metrics.checkpointSeconds ?? null, cpuSeconds: metrics.cpuSeconds ?? null,
peakRssMiB: metrics.peakRssMiB ?? null },
history: training.history || [], rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
model: session ? { id: session.modelId, checkpointId: session.checkpointId, threshold: session.threshold, appliedThreshold: session.appliedThreshold } : null,
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
wheels: submission.wheels, result: submission.result === 'issued' ? 'issued' : errorCategory(submission.result) })) })) } : null,
sensorFields: metrics.policy?.sensorFields || null,
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
events: state.activity.map(({ at, message }) => ({ atUnixMs: at, message })),
unavailable: ['browser_video_latency', 'actual_physical_command_execution', 'live_training_cpu_utilization'],
};
}
module.exports = { report };
@@ -0,0 +1,208 @@
// Autonomy owns a live user's control context, never an administrative identity.
const path = require('node:path');
const { spawn } = require('node:child_process');
const roverManager = require('../roverManager');
const { isVerified, isDeterred } = require('../verificationService');
const { isAdmin } = require('../roleService');
const { submitCommand, issueCommand, subscribeCommandRecording } = require('../commandService');
const { recordCommandRequest } = require('../commandService/recording');
const { acquireModel } = require('./models');
const { compatible } = require('./capabilities');
const OPERATING = new Set(['drive', 'motors', 'servo', 'headlight', 'laser', 'horn', 'peripheral', 'song', 'raw']);
const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value) || 0));
function canControl(socket, roverId) {
return Boolean(socket?.connected && isVerified(socket) && (isAdmin(socket) || !isDeterred(socket))
&& roverManager.rovers.get(roverId)?.ws && roverManager.canDrive(roverId, socket));
}
function createDriving({ root, config, onChange, activity }) {
const sessions = new Map();
const recent = new Map();
let stopping = false;
let watchdog;
let unsubscribe;
function sendState(session, event) {
const input = session.worker.stdin;
if (input.destroyed || !input.writable || input.writableLength > 256 * 1024) return;
input.write(JSON.stringify(event) + '\n');
}
function transmit(session, command) {
if (session.status === 'stopping') return;
if (!canControl(session.socket, session.roverId)) { stop(session.roverId, 'Control permission lost'); return; }
if (!OPERATING.has(command?.type)) throw new Error('Unsupported model command');
const record = roverManager.rovers.get(session.roverId);
if (command.type === 'drive') {
const limit = Math.min(500, Number(record.meta?.maxWheelSpeed) || 500);
command.driveDirect = { left: Math.round(clamp(command.driveDirect?.left, -limit, limit)), right: Math.round(clamp(command.driveDirect?.right, -limit, limit)) };
}
if (command.type === 'motors') {
command.motorPwm = { main: clamp(command.motorPwm?.main, -127, 127), side: clamp(command.motorPwm?.side, -127, 127), vacuum: clamp(command.motorPwm?.vacuum, 0, 127) };
}
if (command.type === 'servo') {
const servo = record.meta?.cameraServo;
if (!servo?.enabled) { session.submissions.push({ command, result: 'Servo unavailable' }); return; }
command.servo.angle = clamp(command.servo?.angle, servo.minAngle ?? 0, servo.maxAngle ?? 180);
}
const { type, ...data } = command;
recordCommandRequest(session.socket, { roverId: session.roverId, type, data }, (result) => {
session.lastResult = result.error || result.reason || 'issued';
session.submissions.push({ command, result: session.lastResult });
if (result.id) session.lastAction = { at: Date.now(), command };
}, (request, reply) => submitCommand(session.socket, request, reply), 'model');
}
function stop(roverId, reason = 'Stopped by user') {
const session = sessions.get(roverId);
if (!session) return Promise.resolve();
if (session.done) return session.done;
session.status = 'stopping';
session.stopReason = reason;
// A stop must still reach the rover after turn permission is revoked.
for (const command of [
{ type: 'drive', driveDirect: { left: 0, right: 0 } },
{ type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } },
{ type: 'horn', horn: { action: 'stop' } },
]) { try { issueCommand(roverId, command); } catch { /* Rover may already be offline. */ } }
session.worker.kill('SIGTERM');
const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000);
session.done = session.closed.then(() => {
clearTimeout(timer);
recent.delete(roverId);
recent.set(roverId, { modelId: session.modelId, stopReason: reason, history: session.history });
if (recent.size > 20) recent.delete(recent.keys().next().value);
sessions.delete(roverId);
activity('Controller stopped', { roverId, reason });
onChange();
});
onChange();
return session.done;
}
async function start(socket, roverId, modelId) {
if (stopping || !canControl(socket, roverId)) throw new Error('You cannot control this rover');
const acquired = await acquireModel(root, modelId);
const { directory, metadata } = acquired;
try {
if (!compatible(metadata, roverManager.rovers.get(roverId))) throw new Error('Model is not compatible with this rover');
if (stopping || !canControl(socket, roverId)) throw new Error('Control permission changed');
if (sessions.has(roverId)) throw new Error('Stop the current controller before starting another model');
} catch (error) { await acquired.release(); throw error; }
const worker = spawn(config.training.python, [path.join(__dirname, 'workers/infer.py'), '--model', directory,
'--url', `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId)}`, '--threads', String(config.driving.threads),
'--threshold', String(config.driving.actionThreshold)], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
const session = { worker, socket, roverId, modelId, checkpointId: metadata.id, modelName: modelId === 'latest' ? 'Latest' : modelId === 'previous' ? 'Previous' : metadata.name, status: 'starting', startedAt: Date.now(), lastPredictionAt: null, threshold: config.driving.actionThreshold, history: [] };
session.closed = new Promise((resolve) => worker.once('close', resolve));
session.closed.then(() => acquired.release()).catch((error) => activity('Checkpoint release failed', { error: error.message }));
sessions.set(roverId, session);
recent.delete(roverId);
let output = '';
let stderr = '';
worker.stdin.on('error', () => { stop(roverId, 'Worker input closed'); });
worker.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1000); });
worker.on('error', (error) => { stop(roverId, error.message); });
worker.once('close', () => { stop(roverId, stderr || 'Worker exited'); });
worker.stdout.on('data', (chunk) => {
if (session.status === 'stopping') return;
output += chunk;
if (output.length > 1024 * 1024) { stop(roverId, 'Worker output exceeded limit'); return; }
let newline;
while ((newline = output.indexOf('\n')) >= 0) {
const line = output.slice(0, newline);
output = output.slice(newline + 1);
try {
const message = JSON.parse(line);
if (message.kind === 'error') throw new Error(message.message);
if (message.kind !== 'prediction') continue;
if (!Number.isFinite(message.frameAt) || Math.abs(Date.now() - message.frameAt) > config.driving.staleMs) throw new Error('Video is stale');
if (!Number.isFinite(message.sensorAt) || Math.abs(Date.now() - message.sensorAt) > config.driving.staleMs) throw new Error('Sensors are stale');
if (!canControl(socket, roverId)) throw new Error('Control permission lost');
session.status = 'running';
session.lastPredictionAt = Date.now();
session.frameAt = message.frameAt;
session.sensorAt = message.sensorAt;
session.latencyMs = message.latencyMs;
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
session.appliedThreshold = message.threshold;
session.wheelSpeeds = message.wheelSpeeds;
session.sensorPresent = message.sensorPresent;
session.submissions = [];
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels';
// Manual commands intentionally do not stop or pause this controller.
for (const command of message.commands) transmit(session, command);
// Bound report history independently of camera and UI frame rates.
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
session.history.push({ at: Date.now(), wheelSpeeds: session.wheelSpeeds,
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason }) => ({ slot, score, reason })),
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
if (session.history.length > 60) session.history.shift();
}
} catch (error) { stop(roverId, error.message); break; }
}
});
const sensors = roverManager.rovers.get(roverId)?.lastSensor?.decoded;
if (sensors) sendState(session, { kind: 'sensor', ts: Date.now(), data: sensors });
activity('Controller started', { roverId, model: metadata.name, socketId: socket.id });
onChange();
}
function sensor(event) {
const session = sessions.get(String(event.roverId));
if (session) sendState(session, { kind: 'sensor', ts: Date.now(), data: event.sensors });
}
function attach() {
roverManager.managerEvents.on('sensor', sensor);
unsubscribe = subscribeCommandRecording((event) => {
const session = sessions.get(String(event.roverId));
if (session && event.phase === 'dispatched') sendState(session, { kind: 'command', ts: event.ts, data: event.command });
});
watchdog = setInterval(() => {
for (const session of sessions.values()) {
if (!canControl(session.socket, session.roverId)) stop(session.roverId, 'Control permission lost');
else if (Date.now() - (session.lastPredictionAt || session.startedAt) > (session.lastPredictionAt ? config.driving.staleMs : 30000)) {
stop(session.roverId, 'No fresh model predictions');
}
}
}, 250);
}
return {
attach, start, stop, canControl,
setThreshold(socket, roverId, value) {
const session = sessions.get(roverId);
if (!session || session.status === 'stopping' || !canControl(socket, roverId)) throw new Error('You cannot adjust this controller');
if (typeof value !== 'number' || !Number.isFinite(value) || value < .05 || value > .99) throw new Error('Threshold must be between 0.05 and 0.99');
session.threshold = value;
sendState(session, { kind: 'settings', ts: Date.now(), threshold: value });
},
getDiagnostics(id) {
const session = sessions.get(id);
return session ? { modelId: session.modelId, sensorPresent: session.sensorPresent, history: session.history } : recent.get(id) || null;
},
isActive: (id) => sessions.has(id),
getState: (id) => {
const session = sessions.get(id);
if (!session) return null;
const { worker, socket, closed, done, history, ...state } = session;
return { ...state, startedBy: socket.data?.nickname || socket.id, socketId: socket.id };
},
shutdown: async () => {
stopping = true;
clearInterval(watchdog);
unsubscribe?.();
roverManager.managerEvents.off('sensor', sensor);
await Promise.all([...sessions.keys()].map((id) => stop(id, 'Service disabled')));
},
kill: () => { for (const session of sessions.values()) session.worker.kill('SIGKILL'); },
};
}
module.exports = { createDriving, canControl };
@@ -0,0 +1,39 @@
// The only resident part while disabled is this configuration lifecycle bridge.
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const logger = require('../../globals/logger').child('roverLearning');
let runtime = null;
let transitions = Promise.resolve();
let shuttingDown = false;
function apply(config) {
const transition = transitions.then(async () => {
if (runtime) {
const previous = runtime;
runtime = null;
await previous.stop();
}
if (!config?.enabled || shuttingDown) return;
const { createRuntime } = require('./runtime');
const next = createRuntime({ config, logger });
try {
await next.start();
runtime = next;
} catch (error) {
await next.stop();
throw error;
}
});
transitions = transition.catch(() => undefined);
return transition;
}
registerConfigurationHandler('roverLearning', apply);
apply(loadConfig().roverLearning).catch((error) => {
logger.error('Unable to start rover learning recording', { error: error.message });
});
// Signal handlers are installed by the enabled runtime, not by this bridge.
module.exports = {
stop: () => { shuttingDown = true; return apply({ enabled: false }); },
};
@@ -0,0 +1,119 @@
// Mutable aliases reference immutable checkpoints. All readers and retention share
// a queue so a model cannot disappear between resolving its alias and loading it.
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID, randomInt } = require('node:crypto');
const UUID = /^[0-9a-f-]{36}$/;
let queue = Promise.resolve();
const leases = new Map();
const retired = new Set();
function serial(task) {
const result = queue.then(task);
queue = result.catch(() => {});
return result;
}
const checkpointRoot = (root) => path.join(root, 'training', 'checkpoints');
async function index(root) {
try { return JSON.parse(await fs.readFile(path.join(checkpointRoot(root), 'index.json'), 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
}
async function metadata(directory) {
const model = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8'));
if (model.id !== path.basename(directory)) throw new Error('Model identity mismatch');
return model;
}
async function prune(root, state) {
const directory = checkpointRoot(root);
await fs.mkdir(directory, { recursive: true });
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (/^\.[0-9a-f-]{36}\.pending$/.test(entry.name)) {
await fs.rm(path.join(directory, entry.name), { recursive: true });
continue;
}
if (!UUID.test(entry.name)) continue;
const target = path.join(directory, entry.name);
if (!Object.values(state).includes(entry.name)) {
if (leases.has(target)) retired.add(target);
else { await fs.rm(target, { recursive: true }); retired.delete(target); }
}
}
}
function adoptCheckpoint(root, model, promote) {
return serial(async () => {
const state = await index(root);
if (!UUID.test(model.id)) throw new Error('Invalid checkpoint identity');
await metadata(path.join(checkpointRoot(root), model.id));
state.candidate = model.id;
if (promote) { state.previous = state.latest || null; state.latest = model.id; }
const filename = path.join(checkpointRoot(root), 'index.json');
await fs.writeFile(`${filename}.tmp`, JSON.stringify(state));
await fs.rename(`${filename}.tmp`, filename);
await prune(root, state);
});
}
function acquireModel(root, id) {
return serial(async () => {
const state = await index(root);
const alias = ['latest', 'previous'].includes(id);
const resolved = alias ? state[id] : id;
if (!UUID.test(resolved || '')) throw new Error('Model is not available');
const directory = path.join(alias ? checkpointRoot(root) : path.join(root, 'models'), resolved);
const model = await metadata(directory);
leases.set(directory, (leases.get(directory) || 0) + 1);
let released = false;
return { directory, metadata: model, release: () => serial(async () => {
if (released) return;
released = true;
const count = leases.get(directory) - 1;
if (count) leases.set(directory, count); else leases.delete(directory);
// Delete only a specific checkpoint already retired by the trainer, not
// a worker's new export awaiting adoption.
if (!count && retired.has(directory)) {
await fs.rm(directory, { recursive: true, force: true });
retired.delete(directory);
}
}) };
});
}
function saveSnapshot(root) {
return serial(async () => {
const state = await index(root);
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
const source = path.join(checkpointRoot(root), state.candidate);
const original = await metadata(source);
const id = randomUUID();
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
const model = { ...original, id, name, createdAt: Date.now(), sourceCheckpointId: original.id,
snapshot: true, experimental: original.evaluation !== 'passed imitation gate' };
const destination = path.join(root, 'models', id);
const pending = path.join(root, 'models', `.${id}.pending`);
await fs.mkdir(pending, { recursive: true });
try {
await fs.copyFile(path.join(source, 'weights.pt'), path.join(pending, 'weights.pt'));
await fs.writeFile(path.join(pending, 'model.json'), JSON.stringify(model));
await fs.rename(pending, destination);
} catch (error) { await fs.rm(pending, { recursive: true, force: true }); throw error; }
return model;
});
}
function listModels(root) {
return serial(async () => {
let entries;
try { entries = await fs.readdir(path.join(root, 'models'), { withFileTypes: true }); }
catch (error) { if (error.code !== 'ENOENT') throw error; entries = []; }
const models = [];
const state = await index(root);
for (const alias of ['latest', 'previous']) {
if (!state[alias]) continue;
const model = await metadata(path.join(checkpointRoot(root), state[alias]));
models.push({ ...model, id: alias, checkpointId: model.id, name: alias === 'latest' ? 'Latest' : 'Previous', automatic: true });
}
for (const entry of entries) {
if (entry.isDirectory() && UUID.test(entry.name)) models.push(await metadata(path.join(root, 'models', entry.name)));
}
return models;
});
}
module.exports = { listModels, acquireModel, adoptCheckpoint, saveSnapshot };
@@ -0,0 +1,182 @@
// One bounded recording session. Video packets retain receiver wall-clock PTS;
// telemetry uses the same server clock, not an assumed browser playback clock.
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawn } = require('node:child_process');
const { controlProfile } = require('./capabilities');
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
async function saveMetadata(directory, metadata) {
const filename = path.join(directory, 'session.json');
await fsp.writeFile(`${filename}.tmp`, JSON.stringify(metadata, null, 2));
await fsp.rename(`${filename}.tmp`, filename);
}
async function createRecording({ root, roverId, snapshot, logger, docked = false }) {
const id = `${Date.now()}-${randomUUID()}`;
const directory = path.join(root, id);
await fsp.mkdir(directory);
const metadata = {
version: 1, id, roverId, startedAt: Date.now(), endedAt: null,
controlProfile: controlProfile(snapshot.meta), wheelSpeedLimit: snapshot.meta?.maxWheelSpeed ?? 500,
timing: 'Video PTS uses server receiver Unix seconds. Events use Unix milliseconds and process monotonic nanoseconds. Browser display delay is not measured.',
video: { file: 'video.mkv', codec: 'source-copy', timestampUnit: 'seconds', origin: 'unix' },
events: 'events.ndjson', droppedEvents: 0, bufferOnly: docked,
trainingStartedAt: docked ? null : Date.now(),
};
await saveMetadata(directory, metadata);
const output = fs.createWriteStream(path.join(directory, 'events.ndjson'), { flags: 'wx' });
let buffered = [];
let bufferedBytes = 0;
let dockedAt = null;
let pruning = Promise.resolve();
let failure = null;
let stopping = false;
let stopPromise;
let child;
let childClosed = false;
let stderr = '';
let videoResult = null;
let resolveVideo;
const videoClosed = new Promise((resolve) => { resolveVideo = resolve; });
output.on('error', (error) => {
failure = error.message;
// Stop file growth immediately when telemetry is no longer writable.
child?.kill('SIGTERM');
});
function append(kind, data, timestamp = {}) {
if (stopping || failure) return false;
try {
const line = JSON.stringify({
ts: Date.now(), monotonicNs: process.hrtime.bigint().toString(), ...timestamp, kind, data,
}) + '\n';
if (metadata.bufferOnly) {
const now = Date.now();
buffered.push({ at: now, line });
bufferedBytes += Buffer.byteLength(line);
while (buffered.length && buffered[0].at < now - 5000) {
bufferedBytes -= Buffer.byteLength(buffered.shift().line);
}
while (bufferedBytes > MAX_BUFFER_BYTES && buffered.length) {
metadata.droppedEvents += 1;
bufferedBytes -= Buffer.byteLength(buffered.shift().line);
}
return true;
}
// Never wait for disk from a control/sensor callback. Explicit loss counts
// prevent an overloaded recorder from pretending its history is complete.
if (output.writableLength + Buffer.byteLength(line) > MAX_BUFFER_BYTES) {
metadata.droppedEvents += 1;
return true;
}
output.write(line);
} catch (error) {
metadata.droppedEvents += 1;
failure = error.message;
child?.kill('SIGTERM');
}
return true;
}
append('snapshot', snapshot);
// Read the local published stream, as replay does, without depending on the
// replay service or re-encoding camera video. No audio/chat is captured.
child = spawn(process.env.FFMPEG_BIN || 'ffmpeg', [
'-hide_banner', '-loglevel', 'warning', '-nostdin', '-n',
'-rtsp_transport', 'tcp', '-timeout', '10000000',
'-use_wallclock_as_timestamps', '1', '-copyts',
'-i', `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId)}`,
'-map', '0:v:0', '-an', '-c:v', 'copy', '-avoid_negative_ts', 'disabled',
// Keyframe-aligned chunks keep a disposable dock buffer without repeatedly
// reconnecting to RTSP. Extra edge packets are filtered by trainingStartedAt.
'-f', 'segment', '-segment_time', '2', '-segment_format', 'matroska',
'-reset_timestamps', '0', path.join(directory, 'video-%09d.mkv'),
], { stdio: ['ignore', 'ignore', 'pipe'] });
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4096); });
child.on('error', (error) => { failure = error.message; });
child.once('close', (code, signal) => {
childClosed = true;
videoResult = { code, signal, stderr };
if (!stopping) failure ||= `Video recorder exited (${code ?? signal})`;
resolveVideo();
});
function updateDock(isDocked) {
if (stopping) return;
if (!isDocked) {
dockedAt = null;
if (metadata.bufferOnly) {
metadata.bufferOnly = false;
metadata.trainingStartedAt = Date.now() - 5000;
for (const item of buffered) output.write(item.line);
buffered = [];
bufferedBytes = 0;
}
} else if (!metadata.bufferOnly && dockedAt == null) dockedAt = Date.now();
}
function pruneBuffer() {
pruning = pruning.then(async () => {
if (!metadata.bufferOnly || stopping) return;
const files = (await fsp.readdir(directory)).filter((name) => /^video-\d+\.mkv$/.test(name)).sort();
// Never unlink the open segment; retain five seconds plus the preceding
// keyframe segment so the decoder can recover the full lead-in.
for (const name of files.slice(0, -2)) {
const filename = path.join(directory, name);
const stat = await fsp.stat(filename);
if (!metadata.bufferOnly || stopping) return;
if (stat.mtimeMs < Date.now() - 5000) await fsp.unlink(filename);
}
}).catch((error) => { failure = error.message; child.kill('SIGTERM'); });
}
async function stop(reason) {
if (stopPromise) return stopPromise;
stopPromise = (async () => {
append('sessionEnd', { reason, droppedEvents: metadata.droppedEvents });
stopping = true;
if (!childClosed) child.kill('SIGTERM');
// A stuck RTSP read must not leave a recorder behind after disable.
const killTimer = setTimeout(() => { if (!childClosed) child.kill('SIGKILL'); }, 2000);
await videoClosed;
await pruning;
clearTimeout(killTimer);
await new Promise((resolve) => {
if (output.closed) return resolve();
output.once('close', resolve);
output.end();
});
if (metadata.bufferOnly) {
await fsp.rm(directory, { recursive: true, force: true });
return;
}
metadata.video.files = (await fsp.readdir(directory)).filter((name) => /^video-\d+\.mkv$/.test(name)).sort();
metadata.endedAt = Date.now();
metadata.reason = reason;
metadata.error = failure;
metadata.video.result = videoResult;
await saveMetadata(directory, metadata);
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, stderr });
})();
return stopPromise;
}
return {
id, directory, startedAt: metadata.startedAt, append, stop, updateDock, pruneBuffer,
get buffered() { return metadata.bufferOnly; },
get dockTailComplete() { return dockedAt != null && Date.now() - dockedAt >= 5000; },
get trainingStartedAt() { return metadata.trainingStartedAt; },
get failed() { return Boolean(failure); },
get stopping() { return stopping; },
getStatus: () => ({ id, roverId, startedAt: metadata.startedAt, droppedEvents: metadata.droppedEvents,
video: childClosed ? 'stopped' : metadata.bufferOnly ? 'Dock lead-in buffer' : 'Recording human driving', error: failure, stopping }),
// Process exit is synchronous; normal disable uses stop() and awaits close.
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
};
}
module.exports = { createRecording, saveMetadata };
@@ -0,0 +1,311 @@
// Enabled-only recording, training, controller, and card lifecycle.
const fs = require('node:fs/promises');
const path = require('node:path');
const { resolveDataPath } = require('../../helpers/dataPaths');
const roverManager = require('../roverManager');
const io = require('../../globals/io');
const { turnEvents } = require('../turnService');
const { modeEvents } = require('../modeManager');
const { isAdmin, roleEvents } = require('../roleService');
const { isDeterred, verificationEvents } = require('../verificationService');
const { subscribeCommandRecording, commandEvents } = require('../commandService');
const { createRecording } = require('./recording');
const { recoverRecordings, enforceRetention } = require('./storage');
const { createTrainer } = require('./training');
const { createDriving } = require('./driving');
const { createGateway } = require('./socketGateway');
function createRuntime({ config, logger }) {
const root = resolveDataPath('rover-learning');
const recordingsRoot = path.join(root, 'recordings');
const recentActivity = [];
function activity(message, details = {}) {
recentActivity.push({ at: Date.now(), message, roverId: details.roverId || null,
detail: details.reason || details.error || details.model || null });
if (recentActivity.length > 40) recentActivity.shift();
}
const baseLogger = logger;
logger = Object.fromEntries(['info', 'warn', 'error'].map((level) => [level, (message, details) => {
baseLogger[level](message, details); activity(message, details);
}]));
const trainer = createTrainer({ root, config: config.training, actionThreshold: config.driving.actionThreshold, logger });
const driving = createDriving({ root, config, onChange: refreshEligibility, activity });
let gateway;
let dockTimer;
let diskState = { bytes: null, free: null };
const sessions = new Map();
const unsubscribers = [];
let running = false;
let timer;
let maintenance = Promise.resolve();
let stopPromise;
let diskPaused = false;
let busy = false;
let wakeRequested = false;
let pending = [];
let pendingBytes = 0;
let droppedPending = 0;
let eligibleRovers = new Set();
function hasHumanController(roverId) {
// Even manual inputs during autonomy remain excluded: model-influenced
// video must not silently become human demonstration training data.
if (driving.isActive(roverId)) return false;
const record = roverManager.rovers.get(roverId);
if (!record?.ws) return false;
// Admin permission alone must not record the whole fleet. Require actual
// driver membership, a live browser socket, and current control eligibility.
for (const socketId of record.drivers || []) {
const socket = io.sockets.sockets.get(socketId);
if (socket?.connected && (isAdmin(socket) || !isDeterred(socket))
&& roverManager.canDrive(roverId, socket)) return true;
}
return false;
}
function currentEligibleRovers() {
return new Set([...roverManager.rovers.keys()].filter(hasHumanController));
}
function refreshEligibility() {
const next = currentEligibleRovers();
const changed = next.size !== eligibleRovers.size || [...next].some((id) => !eligibleRovers.has(id));
eligibleRovers = next;
if (!changed) return;
// Stop the stream immediately even if disk maintenance is in progress.
// The serialized maintenance pass still owns closing/removing the session.
for (const [id, session] of sessions) {
if (!next.has(id)) session.stop('no-human-controller').catch((error) => {
logger.warn('Unable to finalize unattended recording', { roverId: id, error: error.message });
});
}
schedule();
}
function append(kind, event) {
if (!running || event?.roverId == null) return;
const roverId = String(event.roverId);
if (!hasHumanController(roverId)) return;
const timestamp = { ts: Date.now(), monotonicNs: process.hrtime.bigint().toString() };
if (sessions.get(roverId)?.append(kind, event, timestamp)) return;
if (diskPaused || !roverManager.rovers.has(roverId)) return;
// Session creation/rotation is asynchronous. Keep a bounded snapshot of
// events arriving during that gap, with their original observation times.
try {
const serialized = JSON.stringify(event);
const bytes = Buffer.byteLength(serialized);
if (pendingBytes + bytes > 4 * 1024 * 1024) {
droppedPending += 1;
return;
}
pending.push({ roverId, kind, serialized, timestamp, bytes });
pendingBytes += bytes;
} catch { droppedPending += 1; }
}
function listen(emitter, name, handler) {
emitter.on(name, handler);
unsubscribers.push(() => emitter.off(name, handler));
}
function snapshot(record) {
// Do not serialize the live record: it contains sockets and circular state.
return {
meta: record.meta, sensors: record.lastSensor, hostStats: record.lastHostStats,
batteryState: record.batteryState, headlightState: record.headlightState,
laserState: record.laserState, drivers: [...(record.drivers || [])],
locked: record.locked, privateOpen: record.privateOpen,
};
}
async function closeSession(roverId, reason) {
const session = sessions.get(roverId);
if (!session) return;
try {
await session.stop(reason);
} finally {
// Keep closing children reachable by the synchronous process-exit hook.
sessions.delete(roverId);
}
}
async function maintain() {
if (!running) return;
pending = pending.filter((event) => hasHumanController(event.roverId));
pendingBytes = pending.reduce((sum, event) => sum + event.bytes, 0);
for (const [roverId, session] of sessions) {
const record = roverManager.rovers.get(roverId);
if (!hasHumanController(roverId) || session.stopping || session.failed || session.dockTailComplete || (!session.buffered && Date.now() - session.trainingStartedAt >= config.recording.sessionSeconds * 1000)) {
await closeSession(roverId, !record?.ws ? 'offline' : !hasHumanController(roverId) ? 'no-human-controller' : session.failed ? 'recorder-failed' : session.dockTailComplete ? 'docked' : 'rotation');
}
}
if (!running) return;
const retention = await enforceRetention(
recordingsRoot, new Set([...sessions.values()].map((session) => session.id).concat([...trainer.leases])),
config.recording.maxGiB * 1024 ** 3, config.recording.minimumFreeGiB * 1024 ** 3,
);
diskState = retention;
if (retention.paused) {
if (!diskPaused) logger.warn('Recording paused for disk budget', retention);
diskPaused = true;
pending = [];
pendingBytes = 0;
await trainer.cancel();
// Close active files to make them eligible for pruning on the next tick.
await Promise.all([...sessions.keys()].map((id) => closeSession(id, 'disk-budget')));
return;
}
if (diskPaused) logger.info('Recording resumed after disk budget recovery');
diskPaused = false;
for (const [key, record] of roverManager.rovers) {
if (!running) break;
const roverId = String(key);
if (!hasHumanController(roverId) || sessions.has(roverId)) continue;
const session = await createRecording({ root: recordingsRoot, roverId, snapshot: snapshot(record), logger, docked: Boolean(record.lastSensor?.decoded?.chargingSources?.homeBase) });
sessions.set(roverId, session);
// Filesystem awaits can span a disconnect or turn change.
if (!running || !hasHumanController(roverId)) {
await closeSession(roverId, running ? 'no-human-controller' : 'disabled');
continue;
}
if (droppedPending) {
session.append('captureGap', { droppedEventsAcrossRovers: droppedPending });
droppedPending = 0;
}
pending = pending.filter((event) => {
if (event.roverId !== roverId) return true;
session.append(event.kind, JSON.parse(event.serialized), event.timestamp);
pendingBytes -= event.bytes;
return false;
});
}
if (running) {
try {
await trainer.tick(new Set([...sessions.values()].map((session) => session.id)));
} catch (error) {
// Training availability must not tear down otherwise healthy capture.
logger.error('Unable to schedule training', { error: error.message });
}
}
}
function schedule() {
if (!running) return;
if (busy) { wakeRequested = true; return; }
clearTimeout(timer);
busy = true;
// The next tick is scheduled after completion: disk slowness cannot build
// an unbounded backlog of retention tasks or overlapping recorder starts.
maintenance = maintain().catch(async (error) => {
logger.error('Recording maintenance failed; closing recorders', { error: error.message });
await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'storage-error')));
}).finally(() => {
busy = false;
if (running) timer = setTimeout(schedule, wakeRequested ? 0 : 15000);
wakeRequested = false;
});
}
function killChildren() {
driving.kill();
trainer.kill();
for (const session of sessions.values()) session.kill();
}
function onSignal() {
// Other server services can exit the process before asynchronous cleanup
// finishes. Signal children immediately, and keep an exit fallback too.
for (const session of sessions.values()) session.stop('server-shutdown').catch(() => undefined);
stop().catch((error) => logger.warn('Recording shutdown failed', { error: error.message }));
}
async function start() {
await fs.mkdir(recordingsRoot, { recursive: true });
await recoverRecordings(recordingsRoot);
await trainer.start();
running = true;
driving.attach();
dockTimer = setInterval(() => {
for (const [id, session] of sessions) {
session.updateDock(Boolean(roverManager.rovers.get(id)?.lastSensor?.decoded?.chargingSources?.homeBase));
session.pruneBuffer();
if (session.dockTailComplete) schedule();
}
}, 250);
gateway = createGateway({ root, driving, activity, getSystemState: () => ({
recording: { paused: diskPaused, bytes: diskState.bytes, freeBytes: diskState.free,
pendingBytes, droppedPending, sessions: [...sessions.values()].map((session) => session.getStatus()) },
training: trainer.getState(),
limits: { recording: config.recording, training: { ...config.training, python: undefined }, driving: config.driving },
activity: [...recentActivity].reverse(),
}) });
eligibleRovers = currentEligibleRovers();
unsubscribers.push(subscribeCommandRecording((event) => append('command', event)));
// ACK analytics also provide latency/type context; detailed acknowledgements
// arrive through the recording hook and share the command ID.
listen(commandEvents, 'observation', (event) => append('commandOutcome', event));
listen(roverManager.managerEvents, 'sensor', (event) => append('sensor', {
...event,
// The manager stores the exact frame before emitting this synchronous
// event, so raw capture needs no extra hook in rover sensor processing.
frame: roverManager.rovers.get(event.roverId)?.lastSensor?.raw,
}));
for (const kind of ['hostStats', 'driver', 'switch', 'lock', 'private', 'privateSafety', 'help', 'dockGuard']) {
listen(roverManager.managerEvents, kind, (event) => {
append(kind, event);
if (['driver', 'switch', 'lock', 'private', 'privateSafety'].includes(kind)) refreshEligibility();
});
}
for (const kind of ['activeDriver', 'queue']) {
listen(turnEvents, kind, (event) => { append(`turn:${kind}`, event); refreshEligibility(); });
}
listen(modeEvents, 'change', refreshEligibility);
listen(roleEvents, 'change', refreshEligibility);
listen(verificationEvents, 'change', refreshEligibility);
listen(roverManager.managerEvents, 'rover', (event) => {
append('rover', { roverId: event.roverId, action: event.action, snapshot: event.record ? snapshot(event.record) : null });
if (event.action === 'upsert' || event.action === 'removed') refreshEligibility();
});
process.on('SIGTERM', onSignal);
process.on('SIGINT', onSignal);
process.on('exit', killChildren);
busy = true;
maintenance = maintain();
try {
await maintenance;
} finally {
busy = false;
}
if (running) timer = setTimeout(schedule, wakeRequested ? 0 : 15000);
wakeRequested = false;
logger.info('Rover learning enabled', { root, training: config.training.enabled });
}
async function stop() {
if (stopPromise) return stopPromise;
running = false;
gateway?.close();
clearTimeout(timer);
clearInterval(dockTimer);
for (const unsubscribe of unsubscribers.splice(0)) unsubscribe();
stopPromise = (async () => {
const trainingStop = trainer.stop();
const drivingStop = driving.shutdown();
await maintenance.catch(() => undefined);
const results = await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'disabled')));
await trainingStop;
await drivingStop;
process.off('SIGTERM', onSignal);
process.off('SIGINT', onSignal);
process.off('exit', killChildren);
pending = [];
pendingBytes = 0;
const failed = results.find((result) => result.status === 'rejected');
if (failed) throw failed.reason;
})();
return stopPromise;
}
return { start, stop, getTrainingState: trainer.getState };
}
module.exports = { createRuntime };
@@ -0,0 +1,127 @@
// Complete card snapshots; live diagnostics opt into 10 Hz, ordinary cards use 1 Hz.
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { isVerified } = require('../verificationService');
const { isAdmin } = require('../roleService');
const { listModels, saveSnapshot } = require('./models');
const { report } = require('./diagnostics');
const { compatible } = require('./capabilities');
function createGateway({ root, driving, getSystemState, activity }) {
const bindings = new Map();
const viewers = new Map();
let models = [];
let modelError = null;
let lastCatalog = 0;
let closed = false;
let refreshing = false;
let lastOrdinary = 0;
function stateFor(socket, roverId) {
if (!isVerified(socket)) return { available: false };
const visible = roverId && roverManager.canSeeRover(roverId, socket);
const id = visible ? roverId : null;
const session = id ? driving.getState(id) : null;
const canControl = Boolean(id && driving.canControl(socket, id));
const system = getSystemState();
const state = {
available: true, roverId: id, updatedAt: Date.now(),
control: { canSnapshot: isAdmin(socket), canStart: canControl && !session, canAdjust: canControl && Boolean(session), canStop: Boolean(session && (canControl || session.socketId === socket.id)),
reason: !id ? 'Select a rover first' : !canControl ? 'You do not currently have control of this rover' : null },
session,
models: models.filter((model) => model.trainedRovers?.some((rover) => roverManager.canSeeRover(rover, socket)))
.map((model) => ({ id: model.id, checkpointId: model.checkpointId || model.id, automatic: Boolean(model.automatic), experimental: Boolean(model.experimental), name: model.name, createdAt: model.createdAt,
trainingSteps: model.trainingSteps, examplesProcessed: model.examplesProcessed,
weightBytes: model.weightBytes, parameters: model.parameters, trainingLoss: model.trainingLoss,
formatVersion: model.specification?.version, evaluation: model.evaluation, validation: model.validation, distinctMinutes: model.distinctMinutes, controls: model.specification?.commands?.length || 0,
compatible: Boolean(id && compatible(model, roverManager.rovers.get(id))) })),
catalogError: modelError,
recording: { ...system.recording, sessions: system.recording.sessions.filter((s) => roverManager.canSeeRover(s.roverId, socket)) },
training: { ...system.training, sessions: undefined },
limits: system.limits,
activity: system.activity.filter((entry) => !entry.roverId || roverManager.canSeeRover(entry.roverId, socket)),
};
state.diagnostics = viewers.get(socket)?.live ? report(state, id ? driving.getDiagnostics(id) : null) : null;
return state;
}
async function refresh() {
if (closed || refreshing) return;
refreshing = true;
try {
if (Date.now() - lastCatalog >= 15000) {
lastCatalog = Date.now();
try { models = await listModels(root); modelError = null; }
catch (error) { modelError = error.message; }
}
if (closed) return;
const ordinary = Date.now() - lastOrdinary >= 1000;
if (ordinary) lastOrdinary = Date.now();
for (const [socket, view] of viewers) {
if (ordinary || view.live || driving.isActive(view.roverId)) socket.volatile.emit('roverLearning:state', stateFor(socket, view.roverId));
}
} finally { refreshing = false; }
}
function connect(socket) {
const subscribe = (payload = {}, reply) => {
const respond = typeof reply === 'function' ? reply : () => {};
if (!isVerified(socket)) { respond({ error: 'Verification required' }); return; }
const roverId = typeof payload?.roverId === 'string' ? payload.roverId : null;
viewers.set(socket, { roverId, live: payload.live === true });
respond({ state: stateFor(socket, roverId) });
refresh().catch((error) => activity('Card update failed', { error: error.message }));
};
const action = async (payload = {}, reply) => {
const respond = typeof reply === 'function' ? reply : () => {};
try {
if (!isVerified(socket)) throw new Error('Verification required');
const roverId = String(payload.roverId || '');
if (payload.action === 'snapshot') {
if (!isAdmin(socket)) throw new Error('Administrator access required');
const model = await saveSnapshot(root);
models = await listModels(root);
lastCatalog = Date.now();
activity('Learner snapshot saved', { model: model.name });
} else if (payload.action === 'start') await driving.start(socket, roverId, String(payload.modelId || ''));
else if (payload.action === 'threshold') driving.setThreshold(socket, roverId, payload.value);
else if (payload.action === 'stop') {
const session = driving.getState(roverId);
if (!session || !(driving.canControl(socket, roverId) || session.socketId === socket.id)) throw new Error('You cannot stop this controller');
await driving.stop(roverId);
} else throw new Error('Unknown action');
viewers.set(socket, { roverId, live: viewers.get(socket)?.live || false });
respond({ state: stateFor(socket, roverId) });
} catch (error) { respond({ error: error.message }); }
refresh().catch((error) => activity('Card update failed', { error: error.message }));
};
const unsubscribe = () => viewers.delete(socket);
const disconnect = () => { unsubscribe(); detach(socket); };
const handlers = { 'roverLearning:subscribe': subscribe, 'roverLearning:action': action,
'roverLearning:unsubscribe': unsubscribe, disconnect };
for (const [name, handler] of Object.entries(handlers)) socket.on(name, handler);
bindings.set(socket, handlers);
}
function detach(socket) {
for (const [name, handler] of Object.entries(bindings.get(socket) || {})) socket.off(name, handler);
bindings.delete(socket);
}
io.on('connection', connect);
for (const socket of io.sockets.sockets.values()) connect(socket);
const timer = setInterval(() => { refresh().catch((error) => activity('Card update failed', { error: error.message })); }, 100);
refresh().catch((error) => activity('Catalog unavailable', { error: error.message }));
return {
close() {
closed = true;
clearInterval(timer);
io.off('connection', connect);
for (const socket of viewers.keys()) socket.emit('roverLearning:state', { available: false });
viewers.clear();
for (const socket of bindings.keys()) detach(socket);
},
};
}
module.exports = { createGateway };
@@ -0,0 +1,63 @@
// Recording retention only. Published models and training state are never visited.
const fs = require('node:fs/promises');
const path = require('node:path');
const { saveMetadata } = require('./recording');
async function listRecordings(root) {
const recordings = [];
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
// Directory names are generated, never rover IDs or user-supplied paths.
if (!entry.isDirectory() || !/^\d+-[0-9a-f-]{36}$/.test(entry.name)) continue;
const directory = path.join(root, entry.name);
let bytes = 0;
for (const file of await fs.readdir(directory, { withFileTypes: true })) {
if (!file.isFile()) continue;
bytes += (await fs.stat(path.join(directory, file.name))).size;
}
recordings.push({ id: entry.name, directory, bytes, startedAt: Number(entry.name.split('-')[0]) });
}
return recordings.sort((a, b) => a.startedAt - b.startedAt);
}
async function recoverRecordings(root) {
for (const recording of await listRecordings(root)) {
let metadata;
try {
metadata = JSON.parse(await fs.readFile(path.join(recording.directory, 'session.json'), 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
// An interrupted initial metadata write is retained as an unusable session
// until ordinary retention removes it, rather than blocking all startup.
metadata = { version: 1, id: recording.id, startedAt: recording.startedAt };
}
if (metadata.bufferOnly) {
await fs.rm(recording.directory, { recursive: true, force: true });
continue;
}
if (metadata.endedAt != null) continue;
await saveMetadata(recording.directory, {
...metadata, endedAt: Date.now(), reason: 'interrupted',
error: 'Previous process did not close this session; final timestamps and video may be incomplete.',
});
}
}
async function enforceRetention(root, activeIds, maxBytes, minimumFreeBytes) {
const recordings = await listRecordings(root);
let bytes = recordings.reduce((total, recording) => total + recording.bytes, 0);
const stat = await fs.statfs(root);
let free = stat.bavail * stat.bsize;
for (const recording of recordings) {
if (bytes < maxBytes && free >= minimumFreeBytes) break;
if (activeIds.has(recording.id)) continue;
await fs.rm(recording.directory, { recursive: true });
bytes -= recording.bytes;
// Re-read actual free space; sparse/compressed files and other services can
// make a deleted file's logical size a misleading estimate of freed space.
const current = await fs.statfs(root);
free = current.bavail * current.bsize;
}
return { bytes, free, paused: bytes >= maxBytes || free < minimumFreeBytes };
}
module.exports = { recoverRecordings, enforceRetention, listRecordings };
@@ -0,0 +1,185 @@
// One owned finite worker at a time. Leases are acquired in the runtime's
// serialized maintenance pass, before retention can next inspect recordings.
const fs = require('node:fs/promises');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { randomInt } = require('node:crypto');
const { adoptCheckpoint } = require('./models');
const { listRecordings } = require('./storage');
function createTrainer({ root, config, actionThreshold, logger }) {
const leases = new Set();
const trainingRoot = path.join(root, 'training');
let ledger = {};
const history = [];
let child = null;
let completion = Promise.resolve();
let stopped = false;
let nextRun = 0;
let startupError = false;
let state = { status: config.enabled ? 'waiting' : 'disabled', steps: 0, examples: 0 };
async function start() {
if (!config.enabled) return;
try {
await fs.mkdir(trainingRoot, { recursive: true });
await fs.mkdir(path.join(root, 'models'), { recursive: true });
// Only unpublished staging directories from an interrupted old worker
// are disposable. Permanent UUID model directories are never removed.
for (const entry of await fs.readdir(path.join(root, 'models'), { withFileTypes: true })) {
if (entry.isDirectory() && /^\.[0-9a-f-]{36}\.pending$/.test(entry.name)) {
await fs.rm(path.join(root, 'models', entry.name), { recursive: true });
}
}
try {
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v3.json'), 'utf8'));
ledger = saved.sessions;
state = { ...saved.progress, status: 'waiting' };
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
} catch (error) { if (error.code !== 'ENOENT') throw error; }
} catch (error) {
startupError = true;
state = { ...state, status: 'error', error: error.message };
logger.error('Training initialization failed; recording remains available', { error: error.message });
}
}
async function saveLedger() {
const filename = path.join(trainingRoot, 'schedule-v3.json');
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
await fs.rename(`${filename}.tmp`, filename);
}
async function tick(activeIds) {
if (!config.enabled || startupError || stopped || child || Date.now() < nextRun) return;
nextRun = Date.now() + config.intervalSeconds * 1000;
const recordings = await listRecordings(path.join(root, 'recordings'));
const present = new Set(recordings.map((recording) => recording.id));
// Scheduling history is bounded by recording retention, unlike models.
for (const id of Object.keys(ledger)) if (!present.has(id)) delete ledger[id];
const eligible = [];
for (const recording of recordings) {
if (activeIds.has(recording.id) || ledger[recording.id]?.rejected) continue;
try {
const metadata = JSON.parse(await fs.readFile(path.join(recording.directory, 'session.json'), 'utf8'));
if (metadata.bufferOnly || !metadata.endedAt || metadata.error || metadata.droppedEvents || metadata.reason === 'interrupted') continue;
eligible.push(recording.id);
} catch (error) {
ledger[recording.id] = { rejected: `Unreadable session metadata: ${error.message}` };
}
}
// Random cohorts mix old and new retained sessions without repeatedly
// favoring the first files in directory order.
for (let index = eligible.length - 1; index > 0; index -= 1) {
const other = randomInt(index + 1);
[eligible[index], eligible[other]] = [eligible[other], eligible[index]];
}
const selected = eligible.slice(0, 4);
nextRun = Date.now() + config.intervalSeconds * 1000;
if (!selected.length) {
state = { ...state, status: 'waiting', reason: 'Waiting for completed usable human recordings' };
await saveLedger();
return;
}
const jobPath = path.join(trainingRoot, 'job.json');
await fs.writeFile(jobPath, JSON.stringify({ root, config, actionThreshold, sessions: selected }));
if (stopped) return;
selected.forEach((id) => leases.add(id));
state = { ...state, status: 'training', reason: null, sessions: selected };
state.startedAt = Date.now();
child = spawn(config.python, [path.join(__dirname, 'workers/train.py'), '--job', jobPath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', OMP_NUM_THREADS: String(config.threads), MKL_NUM_THREADS: String(config.threads) },
});
let output = '';
let stderr = '';
let result = null;
let errorMessage = null;
let forcedTimer;
const worker = child;
const deadline = setTimeout(() => {
worker.kill('SIGTERM');
forcedTimer = setTimeout(() => worker.kill('SIGKILL'), 5000);
}, (config.maxJobSeconds + 15) * 1000);
child.on('error', (error) => { errorMessage = error.message; });
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-8192); });
child.stdout.on('data', (chunk) => {
output += chunk;
if (output.length > 1024 * 1024) {
errorMessage = 'Training worker output exceeded protocol bound';
worker.kill('SIGKILL');
return;
}
let newline;
while ((newline = output.indexOf('\n')) >= 0) {
const line = output.slice(0, newline);
output = output.slice(newline + 1);
try {
const message = JSON.parse(line);
if (message.kind === 'complete') result = message;
if (message.kind === 'error') errorMessage = message.message;
if (message.kind === 'skipped') logger.warn('Training skipped recording', message);
if (message.kind === 'dataset') state = { ...state, lastSampledWindows: message.sampled, lastAvailableWindows: message.windows, dataset: message.diagnostics };
if (message.kind === 'progress') state = { ...state, steps: message.steps, examples: message.examples, loss: message.loss, losses: message.losses };
} catch { errorMessage = 'Invalid training worker message'; }
}
});
completion = new Promise((resolve) => {
child.once('close', async (code, signal) => {
clearTimeout(deadline);
clearTimeout(forcedTimer);
try {
if (code !== 0 || errorMessage || !result) {
state = { ...state, status: stopped ? 'stopped' : 'error', error: errorMessage || `Worker exited ${code ?? signal}` };
if (!stopped) logger.error('Training job failed', { error: state.error, stderr });
} else {
for (const [id, reason] of Object.entries(result.rejected || {})) ledger[id] = { rejected: reason };
if (result.published) await adoptCheckpoint(root, result.published, result.promote);
if (result.trained) {
for (const id of result.accepted) ledger[id] = { rounds: (ledger[id]?.rounds || 0) + 1 };
}
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
completedAt: Date.now(), elapsedSeconds: result.elapsedSeconds ?? null, error: null,
examples: result.examples ?? state.examples, loss: result.loss ?? null,
diagnostics: result.diagnostics ?? state.diagnostics, publication: result.publication ?? state.publication, evaluation: result.evaluation ?? state.evaluation,
reason: result.reason ?? 'Waiting for the next scheduled training job' };
await saveLedger();
logger.info('Training job completed', { ...state, model: result.published?.name || null });
}
} catch (error) {
state = { ...state, status: 'error', error: error.message };
logger.error('Training result persistence failed', { error: error.message });
} finally {
history.push({ at: Date.now(), status: state.status, steps: state.steps, loss: state.loss,
losses: state.losses || null, evaluation: state.evaluation || null,
elapsedSeconds: state.elapsedSeconds || null });
if (history.length > 20) history.shift();
leases.clear();
child = null;
nextRun = Date.now() + config.intervalSeconds * 1000;
resolve();
}
});
});
}
async function cancel() {
if (!child) return;
const worker = child;
worker.kill('SIGTERM');
const timer = setTimeout(() => worker.kill('SIGKILL'), 5000);
try { await completion; } finally { clearTimeout(timer); }
}
return {
leases, start, tick, cancel,
getState: () => ({ ...state, history, nextAttemptAt: nextRun || null, leasedSessions: leases.size,
visitedSessions: Object.values(ledger).filter((entry) => entry.rounds).length,
skippedSessions: Object.values(ledger).filter((entry) => entry.rejected).length,
rejectionReasons: [...new Set(Object.values(ledger).filter((entry) => entry.rejected).map((entry) => entry.rejected))].slice(0, 10) }),
stop: async () => { stopped = true; await cancel(); },
kill: () => child?.kill('SIGKILL'),
};
}
module.exports = { createTrainer };
@@ -0,0 +1,222 @@
"""Read closed recordings with causal state/action alignment and bounded memory."""
import bisect
import collections
import copy
import json
import math
from pathlib import Path
import av
import numpy as np
from policy import SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
def load_events(directory):
metadata = json.loads((directory / 'session.json').read_text())
if (metadata.get('version') != 1 or not metadata.get('endedAt') or metadata.get('error')
or metadata.get('droppedEvents') or metadata.get('reason') == 'interrupted'):
raise ValueError('Incomplete/interrupted recording or lost events')
if metadata.get('video', {}).get('origin') != 'unix':
raise ValueError('Unsupported video time origin')
video_result = metadata.get('video', {}).get('result') or {}
if video_result.get('signal') == 'SIGKILL' or video_result.get('code') not in (0, 255, None):
raise ValueError('Video recorder failed or was forcibly killed')
event_file = directory / 'events.ndjson'
if event_file.stat().st_size > 64 * 1024 * 1024:
raise ValueError('Event file exceeds 64 MiB read bound')
events = []
with event_file.open() as stream:
for line in stream:
event = json.loads(line)
if event['kind'] == 'captureGap':
raise ValueError('Recording contains a capture gap')
if not isinstance(event.get('ts'), (int, float)) or not math.isfinite(event['ts']):
raise ValueError('Invalid event timestamp')
events.append(event)
if not events or not any(e['kind'] == 'sessionEnd' for e in events):
raise ValueError('Missing completed event stream')
# File order can differ across rotation buffering. Monotonic time detects
# wall-clock steps; sorting Unix timestamps alone would hide those steps.
timed = sorted(events, key=lambda e: int(e['monotonicNs']))
origin = timed[0]
for event in timed:
drift = (event['ts'] - origin['ts']) - (int(event['monotonicNs']) - int(origin['monotonicNs'])) / 1e6
if abs(drift) > 250:
raise ValueError('Server wall clock shifted during recording')
return metadata, sorted(events, key=lambda e: e['ts'])
def prepare(directory, schema, stats):
metadata, events = load_events(directory)
requests = {}
accepted = set()
failed = set()
sensors, dispatches, boundaries = [], [], []
for event in events:
data = event['data']
if event['kind'] == 'sensor':
sensors.append((event['ts'], sensor_vector(data.get('sensors') or {})))
if event['kind'] in ('driver', 'turn:activeDriver', 'lock', 'private'):
boundaries.append(event['ts'])
if event['kind'] != 'command':
continue
if data.get('phase') == 'requested' and data.get('source') == 'client':
requests[data.get('requestId')] = data
if data.get('phase') == 'result' and data.get('result', {}).get('id'):
accepted.add(data['result']['id'])
if data.get('phase') == 'acknowledged' and data.get('acknowledgement', {}).get('error'):
failed.add(data.get('commandId'))
if data.get('phase') == 'dispatched':
dispatches.append(data)
if not sensors:
raise ValueError('No sensor observations')
candidate = copy.deepcopy(schema)
history, targets, wheels = [], [], [(stamp, None) for stamp in boundaries]
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][32:] for row in sensors], axis=0).tolist()})
for data in dispatches:
command = data.get('command') or {}
request = requests.get(data.get('requestId'))
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted
and data.get('commandId') not in failed)
stamp = data['ts']
if command.get('type') == 'drive':
direct = command.get('driveDirect') or {}
pair = [direct.get('left'), direct.get('right')]
if human and all(isinstance(v, (int, float)) and math.isfinite(v) for v in pair):
wheels.append((stamp, np.clip(np.array(pair, dtype=np.float32), -metadata.get('wheelSpeedLimit', 500), metadata.get('wheelSpeedLimit', 500)) / 500))
else:
wheels.append((stamp, None))
elif command.get('type') in ('raw', 'reset', 'reboot', 'update', 'sensorStream'):
# Raw OI can change mode or motion; do not assume the last wheel
# command survives it. A later explicit human drive resolves intent.
wheels.append((stamp, None))
boundaries.append(stamp)
encoded = encode_command(command, candidate, extend=True)
if encoded is None:
continue
stamp = data['ts']
history.append((stamp, *encoded))
request = requests.get(data.get('requestId'))
if (data.get('source') == 'client' and request
and data.get('commandId') in accepted and data.get('commandId') not in failed):
target_time = request['ts']
if not 0 <= stamp - target_time <= 1000:
raise ValueError('Invalid request/dispatch timing')
targets.append((target_time, *encoded))
else:
# Server safety overrides belong in history, never demonstration
# targets. Exclude windows around them rather than teaching inaction.
boundaries.append(stamp)
if not targets:
raise ValueError('No accepted client operating commands')
schema[:] = candidate
return metadata, sensors, sorted(wheels, key=lambda x: x[0]), sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
def windows(directory, schema, cancelled=lambda: False, stats=None):
directory = Path(directory)
stats = stats if stats is not None else {}
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats)
wheel_times = [row[0] for row in wheels]
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0,
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 0,
'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
sensor_times = [row[0] for row in sensors]
target_times = [row[0] for row in targets]
available = np.zeros(SLOTS, dtype=np.float32)
for _, slot, _ in history:
available[slot] = float(schema[slot]['template']['type'] != 'drive')
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
command_times = np.full(SLOTS, -np.inf)
cursor = 0
recent = collections.deque(maxlen=32)
last_sample, last_pts = -math.inf, -math.inf
def frames():
files = metadata['video'].get('files', ['video.mkv'])
for name in files:
if Path(name).name != name:
raise ValueError('Invalid video segment path')
with av.open(str(directory / name)) as container:
stream = container.streams.video[0]
stream.codec_context.thread_count = 1
yield from container.decode(stream)
for frame in frames():
if cancelled():
return
if frame.pts is None:
raise ValueError('Video frame has no timestamp')
stamp = float(frame.pts * frame.time_base) * 1000
if not math.isfinite(stamp) or stamp < last_pts:
raise ValueError('Invalid/nonmonotonic video timestamps')
stats['videoFrames'] += 1
if math.isfinite(last_pts):
stats['maxVideoGapMs'] = max(stats['maxVideoGapMs'], stamp - last_pts)
last_pts = stamp
if not metadata['startedAt'] - 2000 <= stamp <= metadata['endedAt'] + 2000:
raise ValueError('Video PTS is not aligned to session Unix time')
if stamp < metadata.get('trainingStartedAt', metadata['startedAt']):
continue
if stamp - last_sample < 99:
continue
last_sample = stamp
while cursor < len(history) and history[cursor][0] < stamp:
when, slot, values = history[cursor]
command_values[slot] = values
command_times[slot] = when
cursor += 1
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
stats['staleSensorWindows'] += 1
recent.clear()
continue
seen = np.isfinite(command_times).astype(np.float32)
ages = np.minimum((stamp - command_times) / 10000, 1).astype(np.float32)
state = np.concatenate((sensors[sensor_index][1], command_values.flatten(), ages, seen))
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
recent.append((stamp, pixels, state))
chosen = []
for offset in (900, 600, 300, 0):
options = [row for row in recent if row[0] <= stamp - offset + 0.01]
if not options or stamp - offset - options[-1][0] > 200:
break
chosen.append(options[-1])
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
continue
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
stats['boundaryWindows'] += 1
continue
before = bisect.bisect_left(wheel_times, stamp) - 1
after = bisect.bisect_right(wheel_times, stamp + 100) - 1
if before < 0 or after < 0 or wheels[before][1] is None or wheels[after][1] is None:
stats['unknownWheelWindows'] += 1
continue
wheel_target, previous_wheels = wheels[after][1], wheels[before][1]
start = bisect.bisect_left(target_times, stamp)
end = bisect.bisect_left(target_times, stamp + 100)
actions = np.zeros(SLOTS, dtype=np.float32)
values = np.zeros((SLOTS, VALUES), dtype=np.float32)
ambiguous = False
families = set()
for _, slot, vector in targets[start:end]:
template = schema[slot]['template']
family = template['type']
if family == 'drive':
continue
if family == 'peripheral':
peripheral = template.get('peripheral') or {}
family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control'))
if ((actions[slot] and (not schema[slot]['fields'] or not np.array_equal(values[slot], vector)))
or (not actions[slot] and family in families)):
ambiguous = True
break
families.add(family)
actions[slot], values[slot] = 1, vector
if ambiguous:
stats['ambiguousWindows'] += 1
continue
stats['usableWindows'] += 1
stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available, wheel_target, previous_wheels
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Live policy worker. JSON state on stdin, proposals on stdout; no rover writes."""
import argparse
import collections
import hashlib
import json
import math
from pathlib import Path
import sys
import threading
import time
import av
import numpy as np
import torch
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--model', required=True)
parser.add_argument('--url', required=True)
parser.add_argument('--threads', type=int, default=4)
parser.add_argument('--threshold', type=float, default=0.7)
args = parser.parse_args()
torch.set_num_threads(args.threads)
torch.set_num_interop_threads(1)
directory = Path(args.model)
metadata = json.loads((directory / 'model.json').read_text())
if metadata['specification']['version'] != VERSION:
raise ValueError('Model specification is incompatible')
if hashlib.sha256((directory / 'weights.pt').read_bytes()).hexdigest() != metadata['sha256']:
raise ValueError('Model weights checksum mismatch')
schema = metadata['specification']['commands']
model = Policy().eval()
model.load_state_dict(torch.load(directory / 'weights.pt', map_location='cpu', weights_only=True))
observations = collections.deque(maxlen=2048)
mutex = threading.Lock()
closed = threading.Event()
def read_input():
try:
for line in sys.stdin:
event = json.loads(line)
with mutex:
observations.append(event)
finally:
closed.set()
threading.Thread(target=read_input, daemon=True).start()
values = np.zeros((SLOTS, VALUES), dtype=np.float32)
last_commands = np.full(SLOTS, -np.inf)
sensors, sensor_time = {}, 0
history = collections.deque(maxlen=32)
latched = set()
sampled = 0
with av.open(args.url, options={'rtsp_transport': 'tcp', 'timeout': '3000000',
'use_wallclock_as_timestamps': '1'}) as video:
stream = video.streams.video[0]
stream.codec_context.thread_count = 1
for frame in video.decode(stream):
if closed.is_set():
return
if frame.pts is None:
continue
stamp = float(frame.pts * frame.time_base) * 1000
now = time.time() * 1000
if not math.isfinite(stamp) or abs(now - stamp) > 2000:
raise ValueError('Video time is stale or not aligned to server time')
if stamp - sampled < 99:
continue
sampled = stamp
with mutex:
while observations and observations[0]['ts'] < stamp:
event = observations.popleft()
if event['kind'] == 'settings':
args.threshold = max(.05, min(.99, float(event['threshold'])))
elif event['kind'] == 'sensor':
sensors, sensor_time = event['data'], event['ts']
elif event['kind'] == 'command':
try:
encoded = encode_command(event['data'], schema)
if encoded:
slot, vector = encoded
values[slot], last_commands[slot] = vector, event['ts']
except ValueError:
pass # Unseen manual controls do not stop autonomy.
if stamp - sensor_time > 1000:
history.clear()
continue
state = np.concatenate((sensor_vector(sensors), values.flatten(),
np.minimum((stamp - last_commands) / 10000, 1).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()
history.append((stamp, pixels, state))
selected = []
for offset in (900, 600, 300, 0):
candidates = [entry for entry in history if entry[0] <= stamp - offset + .01]
if not candidates or stamp - offset - candidates[-1][0] > 200:
break
selected.append(candidates[-1])
if len(selected) != 4:
continue
before = time.perf_counter()
with torch.inference_mode():
logits, numeric, wheel_output = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
probabilities = logits[0].sigmoid().tolist()
predictions = numeric[0].tolist()
wheel_speeds = [round(value * 500) for value in wheel_output[0].tolist()]
if not all(math.isfinite(value) for value in probabilities):
raise ValueError('Nonfinite model output')
families = {}
active = set()
proposals = []
for slot, descriptor in enumerate(schema):
if descriptor['template']['type'] == 'drive':
continue
command = json.loads(json.dumps(descriptor['template']))
family = command['type']
if family == 'peripheral':
family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control'])
for index, field in enumerate(descriptor['fields']):
number = predictions[slot][index]
if not math.isfinite(number):
raise ValueError('Nonfinite control value')
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
target = command
for key in field['path'][:-1]:
target = target[key]
target[field['path'][-1]] = round(number) if field['integer'] else number
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command,
'reason': 'Below threshold'})
if probabilities[slot] < args.threshold:
continue
active.add(slot)
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
if family not in families or probabilities[slot] > families[family][0]:
families[family] = (probabilities[slot], command, slot, discrete)
latched.intersection_update(active)
# Choose the winning action before latching, so a held toggle cannot
# expose a competing lower-confidence toggle on the following tick.
commands = [command for _, command, slot, discrete in families.values()
if not discrete or slot not in latched]
winners = {row[2]: row for row in families.values()}
for proposal in proposals:
slot = proposal['slot']
if slot in active:
proposal['reason'] = ('Competing action' if slot not in winners else
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed')
for _, _, slot, discrete in families.values():
if discrete:
latched.add(slot)
commands.insert(0, {'type': 'drive', 'driveDirect': dict(zip(('left', 'right'), wheel_speeds))})
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
'latencyMs': (time.perf_counter() - before) * 1000,
'wheelSpeeds': wheel_speeds, 'sensorPresent': sensor_vector(sensors)[32:].tolist(),
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
if __name__ == '__main__':
try:
main()
except Exception as error:
print(json.dumps({'kind': 'error', 'message': str(error)}), flush=True)
raise SystemExit(1)
@@ -0,0 +1,134 @@
"""Versioned, fixed-size vision/control policy and command encoding.
Slots describe command shapes, not arbitrary executable instructions. Numeric
leaves are regressed; string/bool/list values remain exact template constants.
No command is sent by this module. Execution must use server authorization.
"""
import copy
import json
import math
import numpy as np
import torch
from torch import nn
VERSION = 2
SLOTS = 64
VALUES = 8
FRAMES = 4
HEIGHT, WIDTH = 120, 160
OPERATING_TYPES = {"drive", "motors", "servo", "headlight", "laser", "horn", "peripheral", "song", "raw"}
SENSORS = [
("bumpsAndWheelDrops.bumpLeft", 1), ("bumpsAndWheelDrops.bumpRight", 1),
("bumpsAndWheelDrops.wheelDropLeft", 1), ("bumpsAndWheelDrops.wheelDropRight", 1),
("cliffLeft", 1), ("cliffFrontLeft", 1), ("cliffFrontRight", 1), ("cliffRight", 1),
("wall", 1), ("virtualWall", 1), ("wallSignal", 4096),
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
("cliffLeftSignal", 4096), ("cliffRightSignal", 4096),
("distanceMm", 500), ("angleDeg", 180), ("stasis", 1),
]
STATE_SIZE = len(SENSORS) * 2 + SLOTS * (VALUES + 2)
def normalize(value, scale):
return math.atan(float(value) / scale) * 2 / math.pi
def sensor_vector(sensors):
values, present = [], []
for name, scale in SENSORS:
value = sensors
for key in name.split('.'):
value = value.get(key) if isinstance(value, dict) else None
known = isinstance(value, (int, float)) and math.isfinite(value)
values.append(normalize(value, scale) if known else 0)
present.append(float(known))
return np.array(values + present, dtype=np.float32)
def describe_command(command):
if command.get('type') not in OPERATING_TYPES:
return None
if len(json.dumps(command)) > 4096:
raise ValueError('Operating command exceeds 4096-byte schema limit')
values, fields = [], []
def visit(value, path=()):
if isinstance(value, dict):
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
if isinstance(value, (int, float)) and not isinstance(value, bool):
if not math.isfinite(value) or len(values) >= VALUES:
raise ValueError('Nonfinite command or too many numeric control fields')
scale = {'drive': 500, 'motors': 255, 'servo': 180, 'peripheral': 255,
'horn': 1000, 'song': 127}.get(command['type'], 1)
fields.append({'path': list(path), 'scale': scale, 'integer': isinstance(value, int)})
values.append(normalize(value, scale))
return {'$number': len(values) - 1}
# Arrays (e.g. songs) are discrete exact demonstrations, not variable
# length regression outputs. Never reinterpret raw OI bytes as numbers.
return copy.deepcopy(value)
template = visit(command)
return {'template': template, 'fields': fields}, values
def encode_command(command, schema, extend=False):
described = describe_command(command)
if described is None:
return None
descriptor, values = described
try:
slot = schema.index(descriptor)
except ValueError:
if not extend:
raise ValueError('Command shape missing from model schema')
if len(schema) >= SLOTS:
raise ValueError('64 command shapes exhausted; recording skipped, not silently truncated')
slot = len(schema)
schema.append(descriptor)
vector = np.zeros(VALUES, dtype=np.float32)
vector[:len(values)] = values
return slot, vector
class Policy(nn.Module):
def __init__(self):
super().__init__()
self.vision = nn.Sequential(
nn.Conv2d(3, 24, 5, 2, 2), nn.ReLU(),
nn.Conv2d(24, 48, 3, 2, 1), nn.ReLU(),
nn.Conv2d(48, 96, 3, 2, 1), nn.ReLU(),
nn.Conv2d(96, 128, 3, 2, 1), nn.ReLU(),
nn.AdaptiveAvgPool2d((3, 4)), nn.Flatten(),
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
)
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
self.wheels = nn.Linear(128, 2)
self.actions = nn.Linear(128, SLOTS)
self.values = nn.Linear(128, SLOTS * VALUES)
def forward(self, images, state):
batch, frames = images.shape[:2]
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
history, _ = self.history(torch.cat((encoded, state), dim=-1))
last = history[:, -1]
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh(), self.wheels(last).tanh()
def specification(schema):
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2',
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
'historyStrideMs': 300, 'decisionIntervalMs': 100,
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
'commands': schema, 'actionSemantics': 'Continuous wheels at +100ms; accessories are events in the next 100ms',
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'],
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
@@ -0,0 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.14.0+cpu
numpy==2.5.3
av==18.1.0
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""One finite CPU training job; Node owns scheduling, leases, and shutdown.
Usage: python train.py --job /path/to/job.json
Dependencies are packaged at image build time, never installed at runtime.
"""
import argparse
import copy
import hashlib
import json
import os
from pathlib import Path
import random
import resource
import signal
import time
import uuid
def emit(kind, **data):
print(json.dumps({'kind': kind, **data}, allow_nan=False), flush=True)
def atomic_torch_save(torch, value, path):
temporary = path.with_suffix('.tmp')
with temporary.open('wb') as stream:
torch.save(value, stream)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--job')
parser.add_argument('--check-dependencies', action='store_true', help='Describe the installed production policy and exit without creating storage')
args = parser.parse_args()
# --help works even before Python dependencies are installed.
import numpy as np
import av
import torch
from torch.nn import functional as functional
from dataset import windows
from policy import Policy, VERSION, SLOTS, VALUES, specification
if args.check_dependencies:
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
parameters=sum(p.numel() for p in Policy().parameters()), specification=specification([]))
return
if not args.job:
parser.error('--job is required unless --check-dependencies is used')
job = json.loads(Path(args.job).read_text())
root = Path(job['root'])
config = job['config']
torch.set_num_threads(config['threads'])
torch.set_num_interop_threads(1)
# Training yields CPU priority to the Node/media processes. Thread count is
# an additional bound, not a claim to reserve specific physical cores.
os.nice(10)
stopped = False
def stop(_signal, _frame):
nonlocal stopped
stopped = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
started = time.monotonic()
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
model = Policy().cpu()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
# A fresh training lineage prevents previously trained footage leaking into validation.
resume_path = root / 'training' / 'resume-v3.pt'
schema, steps, examples, last_published = [], 0, 0, 0
trained_rovers = set()
control_profiles = []
distinct_windows = {}
published_windows = 0
if resume_path.exists():
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
if saved['version'] != VERSION:
raise ValueError('Resume schema incompatible; refusing to reinterpret existing weights')
model.load_state_dict(saved['weights'])
optimizer.load_state_dict(saved['optimizer'])
schema = saved['schema']
steps, examples = saved['steps'], saved['examples']
last_published = saved['lastPublished']
trained_rovers = set(saved['trainedRovers'])
control_profiles = saved.get('controlProfiles', [])
distinct_windows = saved.get('distinctWindows', {})
published_windows = saved.get('publishedWindows', 0)
cpu_started = time.process_time()
decode_started = time.monotonic()
dataset_stats = []
initial_steps = steps
pool, validation, accepted, rejected = [], [], [], {}
observed_windows = {}
sessions = list(job['sessions'])
random.shuffle(sessions)
is_held_out = lambda session_id: int(hashlib.sha256(session_id.encode()).hexdigest()[:8], 16) % 5 == 0
sessions.sort(key=is_held_out)
per_session = max(1, config['maxSamples'] // max(1, len(sessions)))
for session_id in sessions:
if cancelled():
break
candidate_schema = copy.deepcopy(schema)
samples, seen = [], 0
stats = {}
buckets = {key: [] for key in ('stopped', 'forward', 'reverse', 'turn')}
counts = dict.fromkeys(buckets, 0)
held_out = is_held_out(session_id)
try:
directory = root / 'recordings' / session_id
for sample in windows(directory, candidate_schema, cancelled, stats):
seen += 1
# Keep evaluation naturally distributed. Training reserves space
# for ordinary turns/reversing so idle or straight motion cannot
# consume the entire sample budget. No scripted driving required.
if held_out:
reservoir, count, capacity = samples, seen, per_session
else:
left, right = sample[5]
category = ('stopped' if max(abs(left), abs(right)) <= .02 else
'turn' if abs(left - right) > .1 else
'reverse' if left + right < 0 else 'forward')
counts[category] += 1
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 4)
if len(reservoir) < capacity:
reservoir.append(sample)
else:
index = random.randrange(count)
if index < capacity:
reservoir[index] = sample
if not held_out:
samples = [sample for bucket in buckets.values() for sample in bucket]
if cancelled():
break # Do not call a partially decoded recording validated.
if not samples:
raise ValueError('No complete, aligned observation/action windows')
# Entire sessions stay in one partition across all jobs. Neighboring
# video windows must never straddle training and validation.
held_out = is_held_out(session_id)
if held_out:
if candidate_schema != schema:
dataset_stats.append({**stats, 'partition': 'heldOut', 'sampledWindows': len(samples),
'skipReason': 'Vocabulary absent from training data'})
continue # Retry after training data introduces these command shapes.
validation.extend(samples)
else:
schema = candidate_schema
pool.extend(samples)
observed_windows[session_id] = seen
accepted.append(session_id)
metadata = json.loads((directory / 'session.json').read_text())
if not held_out:
trained_rovers.add(str(metadata['roverId']))
profile = metadata.get('controlProfile')
if not held_out and profile is not None and profile not in control_profiles:
control_profiles.append(profile)
stats.update({'partition': 'heldOut' if held_out else 'training', 'sampledWindows': len(samples)})
dataset_stats.append(stats)
emit('dataset', sessionId=session_id, windows=seen, sampled=len(samples), diagnostics=stats)
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
rejected[session_id] = str(error)[:500]
emit('skipped', sessionId=session_id, reason=rejected[session_id])
if len(pool) < config['minimumSamples'] or not any(np.max(np.abs(sample[5])) > .02 for sample in pool) or cancelled():
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
return
decode_seconds = time.monotonic() - decode_started
train_started = time.monotonic()
numeric_mask = torch.zeros(SLOTS, VALUES)
for slot, descriptor in enumerate(schema):
numeric_mask[slot, :len(descriptor['fields'])] = 1
# Balance sparse events without assuming that every time step should fire
# every control. This is training loss, not a driving-quality evaluation.
positives = np.sum([sample[2] for sample in pool], axis=0)
opportunities = np.sum([sample[4] for sample in pool], axis=0)
positive_weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
losses = []
model.train()
for _ in range(config['passesPerJob']):
random.shuffle(pool)
for offset in range(0, len(pool), config['batchSize']):
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
break
batch = pool[offset:offset + config['batchSize']]
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
images = images.float().div_(255)
optimizer.zero_grad(set_to_none=True)
action_logits, predictions, wheel_predictions = model(images, states)
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
pos_weight=positive_weight, reduction='none')
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets)
loss = wheel_loss + event_loss + value_loss
if not torch.isfinite(loss):
raise ValueError('Nonfinite training loss; checkpoint not saved')
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
optimizer.step()
steps += 1
examples += len(batch)
losses.append(loss.item())
if steps % 10 == 0:
emit('progress', steps=steps, examples=examples, loss=loss.item(),
losses={'wheels': wheel_loss.item(), 'events': event_loss.item(), 'accessoryValues': value_loss.item()})
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
break
if steps == initial_steps:
emit('complete', trained=False, accepted=[], rejected=rejected, reason='No training steps completed')
return
training_seconds = time.monotonic() - train_started
evaluation_started = time.monotonic()
distinct_windows.update(observed_windows)
unique_count = sum(distinct_windows.values())
evaluation = {'status': 'waiting for held-out sessions', 'windows': len(validation)}
if validation and not cancelled():
model.eval()
true_positive = predicted_positive = actual_positive = 0
value_error = value_count = 0
evaluated = 0
wheel_error = stopped_error = persistence_error = 0
changed_error = changed_persistence = changed_count = moving_count = 0
with torch.inference_mode():
for offset in range(0, len(validation), config['batchSize']):
if cancelled():
break
batch = validation[offset:offset + config['batchSize']]
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
logits, predictions, wheel_predictions = model(images.float() / 255, states)
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool()
actual = targets.bool() & available.bool()
true_positive += (predicted & actual).sum().item()
predicted_positive += predicted.sum().item()
actual_positive += actual.sum().item()
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
value_error += ((predictions - values).square() * mask).sum().item()
value_count += mask.sum().item()
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02
changed_error += errors[changed].sum().item()
changed_persistence += persistence[changed].sum().item()
changed_count += changed.sum().item()
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item()
wheel_error += errors.sum().item()
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500
persistence_error += persistence.sum().item()
evaluated += len(batch)
precision = true_positive / max(1, predicted_positive)
recall = true_positive / max(1, actual_positive)
mse = value_error / max(1, value_count)
wheel_mae = wheel_error / max(1, evaluated)
stopped_mae = stopped_error / max(1, evaluated)
persistence_mae = persistence_error / max(1, evaluated)
changed_mae = changed_error / max(1, changed_count)
changed_baseline = changed_persistence / max(1, changed_count)
# Check control changes separately: copying the previous speed can look
# excellent on long straight runs without learning to steer or stop.
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32
and changed_count >= 32 and wheel_mae < stopped_mae
and wheel_mae <= persistence_mae and changed_mae < changed_baseline)
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else
actual_positive >= 32 and precision >= .5 and recall >= .5)
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1)
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met',
'windows': evaluated, 'precision': precision, 'recall': recall,
'numericMse': mse, 'positiveActions': actual_positive,
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae,
'previousSpeedBaselineMaeMmPerSecond': persistence_mae,
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline,
'changedWindows': changed_count, 'movingWindows': moving_count,
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass}
evaluation_seconds = time.monotonic() - evaluation_started
publication = {'distinctMinutes': unique_count / 600,
'newMinutes': (unique_count - published_windows) / 600,
'minimumMinutes': config['minimumDrivingMinutes'],
'requiredNewMinutes': config['newDrivingMinutesPerModel']}
reasons = []
if publication['distinctMinutes'] < config['minimumDrivingMinutes']:
reasons.append('Collecting distinct driving footage')
if publication['newMinutes'] < config['newDrivingMinutesPerModel']:
reasons.append('Waiting for new driving footage since last publication')
if steps - last_published < config['checkpointEverySteps']:
reasons.append('Training steps below publication interval')
if evaluation['status'] != 'passed imitation gate':
reasons.append(evaluation['status'])
if cancelled():
reasons.append('Job time budget reached')
publication['reason'] = '; '.join(reasons) or 'Latest updated'
save_started = time.monotonic()
published = None
# Every completed job exports an immutable candidate; Node owns promotion and retention.
adjectives = ['amber', 'curious', 'gentle', 'quiet', 'silver', 'bright', 'merry', 'sleepy']
animals = ['otter', 'finch', 'badger', 'robin', 'fox', 'heron', 'marten', 'wren']
model_id = str(uuid.uuid4())
name = f'{random.choice(adjectives)}-{random.choice(animals)}-{model_id[:8]}'
checkpoint_root = root / 'training' / 'checkpoints'
checkpoint_root.mkdir(exist_ok=True)
staging = checkpoint_root / f'.{model_id}.pending'
staging.mkdir()
weights_path = staging / 'weights.pt'
atomic_torch_save(torch, model.state_dict(), weights_path)
published = {
'id': model_id, 'name': name, 'createdAt': int(time.time() * 1000),
'trainingSteps': steps, 'examplesProcessed': examples,
'parameters': sum(p.numel() for p in model.parameters()),
'weightBytes': weights_path.stat().st_size,
'sha256': hashlib.sha256(weights_path.read_bytes()).hexdigest(),
'specification': specification(schema), 'trainedRovers': sorted(trained_rovers),
'controlProfiles': control_profiles,
'sourceSessions': accepted, 'trainingLoss': sum(losses) / len(losses),
'evaluation': evaluation['status'], 'validation': evaluation, 'distinctMinutes': publication['distinctMinutes'], 'dtype': 'float32', 'torchVersion': str(torch.__version__),
}
(staging / 'model.json').write_text(json.dumps(published, indent=2, allow_nan=False))
# A catalog reader sees both weights and metadata, or neither. Existing
# versions are never overwritten or deleted by the trainer.
staging.rename(checkpoint_root / model_id)
if not reasons:
last_published = steps
published_windows = unique_count
atomic_torch_save(torch, {
'version': VERSION, 'weights': model.state_dict(), 'optimizer': optimizer.state_dict(),
'schema': schema, 'steps': steps, 'examples': examples,
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
'controlProfiles': control_profiles, 'distinctWindows': distinct_windows,
'publishedWindows': published_windows,
}, resume_path)
diagnostics = {'dataset': dataset_stats, 'decodeSeconds': decode_seconds,
'trainingSeconds': training_seconds, 'evaluationSeconds': evaluation_seconds, 'checkpointSeconds': time.monotonic() - save_started,
'cpuSeconds': time.process_time() - cpu_started,
'peakRssMiB': resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
'dependencies': {'torch': str(torch.__version__), 'numpy': str(np.__version__), 'av': str(av.__version__)},
'policy': specification(schema), 'browserVideoLatencyMs': None}
emit('complete', trained=True, accepted=accepted, rejected=rejected,
steps=steps, examples=examples, loss=sum(losses) / len(losses),
published=published, promote=not reasons, publication=publication, evaluation=evaluation, diagnostics=diagnostics, elapsedSeconds=time.monotonic() - started)
if __name__ == '__main__':
try:
main()
except Exception as error:
emit('error', message=str(error))
raise SystemExit(1)
+1121 -1256
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
"build": "vite build",
"lint": "eslint .",
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
@@ -15,12 +15,15 @@
"@rjsf/core": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@thumbmarkjs/thumbmarkjs": "^1.10.0",
"linkify-react": "^4.3.3",
"linkifyjs": "^4.3.3",
"midi-file": "^1.2.4",
"papaparse": "^5.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"react-icons": "^5.7.0",
"react-joystick-component": "^6.2.1",
"react-player": "^3.4.0",
"react-router-dom": "^7.9.6",
"three": "^0.184.0",
"uplot": "^1.6.32",
@@ -28,18 +31,17 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"autoprefixer": "^10.4.22",
"baseline-browser-mapping": "^2.11.25",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"socket.io-client": "4.8.3",
"tailwindcss": "^3.4.14",
"tailwindcss": "^4.3.3",
"vite": "^7.2.2",
"vitest": "^5.0.0"
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -137,7 +137,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
return (
<div ref={editorRef} className="configuration-editor">
<CardFrame title="Configuration" meta={`revision ${revision}`} clipOverflow={false} bodyClassName="p-0.5">
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
<div ref={toolbarRef} className="configuration-toolbar sticky top-0 z-20 mb-0.5 space-y-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur-sm">
<p className="text-xs text-slate-400">Saving applies the complete revision immediately and reloads each affected service.</p>
{/* All document actions stay together at the start of the toolbar. The
editor may use a wide canvas, but width is never used to separate a
@@ -21,7 +21,7 @@ export default function PasswordConfirmationDialog({ open, busy, error, onCancel
}
return (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/75 p-2">
<div className="fixed inset-0 z-1000 flex items-center justify-center bg-black/75 p-2">
<form className="w-full max-w-sm border border-neutral-500/70 bg-neutral-900 p-1 text-slate-100 shadow-2xl" onSubmit={submit}>
<h2 className="text-base font-semibold">Confirm password</h2>
<p className="mt-0.5 text-xs text-slate-400">This sensitive action requires recent confirmation of your administrator password.</p>
+4 -1
View File
@@ -3,6 +3,9 @@
* Scope: Keeps route-specific hierarchy and RJSF integration out of the shared global stylesheet.
*/
/* Reuse the shared theme without emitting a second copy of Tailwind. */
@reference '../index.css';
.configuration-tree {
/* The large gap is intentionally limited to root siblings. It makes each
top-level configuration section read as a separate YAML block while the
@@ -91,7 +94,7 @@
.configuration-value input:not([type='checkbox']),
.configuration-value select,
.configuration-value textarea {
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-sky-500;
@apply w-full rounded-md border border-neutral-600 bg-neutral-700 px-0.5 py-0.5 text-white placeholder:text-slate-400 focus:outline-hidden focus:ring-1 focus:ring-sky-500;
}
.configuration-value input[type='checkbox'] {
@@ -504,7 +504,7 @@ export default function AdminPanelContent() {
value={reasonDraft}
onChange={(event) => setReasonDraft(event.target.value)}
placeholder="Set an admin mode reason"
className="field-input text-sm min-h-[3.5rem]"
className="field-input text-sm min-h-14"
/>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleReasonSave} className="button-dark">
@@ -558,7 +558,7 @@ export default function AdminPanelContent() {
{updateStates[rover.id] ? 'Updating...' : 'Update'}
</button>
{rover?.private?.enabled ? (
<div className="w-full rounded border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="w-full rounded-sm border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="text-slate-300">Private safety</div>
<label className="flex items-center gap-0.5">
<input
@@ -74,28 +74,28 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
{statPills.map((pill) => (
<span
key={pill.label}
className="rounded border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
className="rounded-sm border border-slate-600/60 bg-slate-800/70 px-0.5 py-0.25 text-[0.72rem] leading-tight text-slate-200"
>
{pill.label}: {pill.value}
</span>
))}
</div>
{errors.message ? (
<div className="surface text-xs text-red-300 break-words">Error: {errors.message}</div>
<div className="surface text-xs text-red-300 wrap-break-word">Error: {errors.message}</div>
) : null}
{errors.details ? (
<details className="surface text-xs text-red-200">
<summary className="cursor-pointer select-none text-red-300">Failure details</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-red-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-red-200">
{JSON.stringify(errors.details, null, 2)}
</pre>
</details>
) : null}
{output.generated ? (
<div className="surface text-xs text-slate-200 break-words">Generated: {output.generated}</div>
<div className="surface text-xs text-slate-200 wrap-break-word">Generated: {output.generated}</div>
) : null}
{output.posted ? (
<div className="surface text-xs text-emerald-200 break-words">Posted: {output.posted}</div>
<div className="surface text-xs text-emerald-200 wrap-break-word">Posted: {output.posted}</div>
) : null}
<div className="grid gap-0.5 md:grid-cols-2">
<div className="space-y-0.5">
@@ -120,7 +120,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
</div>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Full Monitor Payload</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(state, null, 2)}
</pre>
</details>
@@ -154,7 +154,7 @@ export default function LlmCommentaryPanel({ state, onClearHistory, clearingHist
{selectedRun ? (
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Run #{selectedRun.runId} details</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(selectedRun, null, 2)}
</pre>
</details>
@@ -58,49 +58,49 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{input.stateUpdate || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{renderModelMessages()}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{input.systemPrompt || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(input.transcript || [], null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{output.raw || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(output.liveToolCalls || [], null, 2)}
</pre>
</details>
@@ -115,7 +115,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
<pre className="mt-0.5 whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">
{JSON.stringify(state.history || [], null, 2)}
</pre>
</details>
@@ -124,7 +124,7 @@ export default function OverseerControlPanel({ state, onClearHistory, clearingHi
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
</button>
{showPayload ? (
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
<pre className="surface whitespace-pre-wrap wrap-break-word text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
) : null}
</div>
);
@@ -0,0 +1,133 @@
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
function getSafeWikiUrl(rawUrl) {
const text = String(rawUrl || '').trim();
if (!text) return '';
try {
/*
Wiki links are operator-authored registry data, but this still normalizes
them before rendering an anchor. Allowing only http/https avoids turning a
barcode registry typo into a javascript: link while still supporting both
absolute URLs and local wiki paths such as /wiki/object-name.
*/
const parsed = new URL(text, window.location.origin);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.href;
} catch {
return '';
}
}
function BarcodeScanToast({ payload }) {
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
const label = payload?.label || payload?.code || 'unknown barcode';
return (
<div className="pointer-events-auto max-w-[80vw] rounded-md bg-neutral-800 px-2 py-1 text-l leading-tight text-slate-100">
<style>
{`
@keyframes barcode-wiki-flat-flash {
0%, 100% {
background: rgb(233, 186, 14);
color: rgb(71, 144, 253);
}
50% {
background: rgb(125, 49, 255);
color: rgb(246, 250, 211);
}
}
`}
</style>
<p className="flex flex-wrap items-center gap-0.5 text-slate-100">
<span className="font-semibold text-slate-300 pr-1">Barcode scanned:</span>
<span className="font-semibold text-white">{label}</span>
{wikiUrl ? (
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="rounded-md ml-0.5 px-2 py-1 text-xl font-semibold leading-tight tracking-normal"
style={{ animation: 'barcode-wiki-flat-flash 0.35s steps(1, end) infinite' }}
>
Wiki link
</a>
) : null}
</p>
</div>
);
}
function UndockedExitWarningToast() {
return (
<div
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
export default function AlertContent({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
return (
<div className={`pointer-events-none w-50 max-w-full rounded-md ${payload.limited ? 'bg-red-950' : 'bg-cyan-900'}`}>
<ButtonBoxTile
buttonId={payload.buttonId}
count={payload.count}
goal={payload.goal}
rewardNumber={payload.rewardNumber}
rewardName={payload.rewardName}
rewardDescription={payload.rewardDescription}
dailyCount={payload.dailyCount}
dailyLimit={payload.dailyLimit}
limited={payload.limited}
className={payload.limited ? 'bg-red-950 ring-1 ring-red-500/70' : 'bg-cyan-900'}
/>
{payload.description ? (
<p className={['mt-1 text-center text-xs font-semibold', payload.limited ? 'text-red-200' : 'text-cyan-100'].join(' ')}>
{payload.description}
</p>
) : null}
</div>
);
}
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
if (alert.kind === 'chat-typing' && alert.payload) {
return (
// Override only the feed's typing surface so whole-alert opacity has one owner.
<div className="[&>div]:bg-slate-900 [&>div]:opacity-100">
<ChatMessageRow message={alert.payload} variant="typing" />
</div>
);
}
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
return (
<div
className="pointer-events-auto min-w-0 max-w-96 rounded-md px-2 py-1 text-left text-[0.72rem] leading-tight text-white"
style={{ backgroundColor: `color-mix(in srgb, ${backgroundColor} 65%, black)` }}
>
<p className="truncate">
<span className="font-semibold">{alert.title || 'Alert'}</span>
<span className="text-white/70"> · </span>
<span className="text-white">{alert.message}</span>
</p>
</div>
);
}
+68 -534
View File
@@ -1,561 +1,95 @@
// Alert Feed
// Purpose: Defines the Alert Feed module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
import ChatMessageRow from '../ChatMessageRow/index.jsx';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import useLayoutMode from '../../hooks/useLayoutMode.js';
import AlertContent from './AlertContent.jsx';
import useAlertEvents from './useAlertEvents.js';
const LIFETIME_MS = 3000;
const BUTTONBOX_LIFETIME_MS = 5000;
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
const MAX_VISIBLE_TOASTS = 6;
const RECENT_ALERT_LOOKBACK = 12;
const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) {
if (alert.id) return alert.id;
if (alert.timestamp) return `${alert.timestamp}-${alert.message}`;
return `${alert.title || 'alert'}-${alert.message}`;
function getAlertKey(alert) {
return alert.id || (alert.timestamp
? `${alert.timestamp}-${alert.message}`
: `${alert.title || 'alert'}-${alert.message}`);
}
function getAlertLifetime(alert) {
return Number.isFinite(alert?.lifetimeMs) ? alert.lifetimeMs : LIFETIME_MS;
}
function buildAlertToken(alert, key) {
/*
A keyed alert such as a barcode scan intentionally reuses the same id so the
visual slot can be refreshed in place. The token must therefore include the
arrival timestamp too; otherwise a new scan with the same id would look like
the same toast and would not get a fresh timer.
*/
const receivedAt = alert?.receivedAt ?? '';
const timestamp = alert?.timestamp ?? '';
const title = alert?.title ?? '';
const message = alert?.message ?? '';
return `${key}:${receivedAt}:${timestamp}:${title}:${message}`;
}
function buildToastRecord(item) {
const lifetimeMs = getAlertLifetime(item.alert);
const expiresAt = Date.now() + lifetimeMs;
return {
key: item.key,
alert: item.alert,
expiresAt,
remainingMs: lifetimeMs,
paused: false,
};
}
function getToastRgb(alert) {
return hexToRgb(alert?.color) || hexToRgb(DEFAULT_COLOR);
}
function getToastAccent(alert) {
const rgb = getToastRgb(alert);
return rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : DEFAULT_COLOR;
}
function isGenericAlert(alert) {
/*
The shell needs to know which alerts use the fallback title/message renderer
because those alerts historically carried their own color-tinted surface.
Matching the same payload guards used by AlertToast keeps the shell styling
aligned with the renderer that will actually be selected below.
*/
if (alert?.kind === 'buttonbox-active' && alert.payload) return false;
if (alert?.kind === 'chat' && alert.payload) return false;
if (alert?.kind === 'chat-typing' && alert.payload) return false;
if (alert?.kind === 'barcode-scan' && alert.payload) return false;
if (alert?.kind === 'undocked-exit-warning') return false;
return true;
}
export default function AlertFeed({ scale = 1 }) {
export default function AlertFeed({ scale = 1, opacity = 0.6 }) {
const layout = useLayoutMode();
const maxVisibleToasts = layout === 'mobile-portrait' || layout === 'mobile-landscape' ? 4 : 10;
const alerts = useSessionSelector((state) => state.alerts);
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const { pushAlert } = useSessionActions();
const socket = useSocket();
const [toasts, setToasts] = useState([]);
const latestTokensRef = useRef(new Set());
const timersRef = useRef(new Map());
const seenRef = useRef(new Set());
useAlertEvents();
const clearToastTimer = useCallback((key) => {
const timer = timersRef.current.get(key);
if (!timer) return;
clearTimeout(timer);
timersRef.current.delete(key);
useEffect(() => {
// Session history preserves object identity, including distinct arrivals
// with the same id and timestamp. Dismissed alerts must not reappear.
const incoming = alerts.slice(-RECENT_ALERT_LOOKBACK).filter((alert) => !seenRef.current.has(alert));
seenRef.current = new Set(alerts);
setToasts((current) => {
if (!incoming.length && current.length <= maxVisibleToasts) return current;
const next = new Map(current.map((alert) => [getAlertKey(alert), alert]));
for (const alert of incoming) {
const key = getAlertKey(alert);
// Refreshed alerts move to the newest slot and receive a fresh lifetime.
next.delete(key);
next.set(key, alert);
}
return [...next.values()].slice(-maxVisibleToasts);
});
}, [alerts, maxVisibleToasts]);
const dismiss = useCallback((alert) => {
setToasts((current) => current.filter((entry) => entry !== alert));
}, []);
const removeToast = useCallback(
(key) => {
clearToastTimer(key);
setToasts((currentToasts) => currentToasts.filter((toast) => toast.key !== key));
},
[clearToastTimer],
);
const scheduleToastRemoval = useCallback(
(key, delayMs) => {
clearToastTimer(key);
/*
Each toast owns exactly one removal timeout. This is cheaper and simpler
than polling all visible alerts for progress/fade state, and it still
lets hover/focus pause by clearing the one timer for the interacted row.
*/
const timer = setTimeout(() => removeToast(key), Math.max(0, delayMs));
timersRef.current.set(key, timer);
},
[clearToastTimer, removeToast],
);
useEffect(
() => () => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
},
[],
);
useEffect(() => {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
/*
Event fields win over session fields because the button-box event is the
exact outcome of this press. Session sync follows immediately after, but
using it first can make fast toasts briefly show stale count/limit data.
*/
const goal = Number.isFinite(payload.goal) ? payload.goal : Number.isFinite(button.goal) ? button.goal : 0;
const dailyCount = Number.isFinite(payload.dailyCount)
? payload.dailyCount
: Number.isFinite(button.dailyCount)
? button.dailyCount
: 0;
const dailyLimit = Number.isFinite(payload.dailyLimit)
? payload.dailyLimit
: Number.isFinite(button.dailyLimit)
? button.dailyLimit
: null;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const rewardName =
typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
const rewardDescription =
typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
? button.rewardDescription.trim()
: null;
const description =
typeof payload.description === 'string' && payload.description.trim()
? payload.description.trim()
: null;
pushAlert({
id: `buttonbox-active-${buttonId}`,
kind: 'buttonbox-active',
lifetimeMs: BUTTONBOX_LIFETIME_MS,
payload: {
buttonId,
count,
goal,
dailyCount,
dailyLimit,
limited: Boolean(payload.limited),
description,
rewardNumber,
rewardName,
rewardDescription,
},
});
}
socket.on('buttonBox:increment', onButtonIncrement);
return () => {
socket.off('buttonBox:increment', onButtonIncrement);
};
}, [pushAlert, buttonBoxButtons, socket]);
useEffect(() => {
function onBarcodeScanned(payload = {}) {
const label = typeof payload.label === 'string' && payload.label.trim()
? payload.label.trim()
: payload.code || 'unknown barcode';
/*
Barcode scans use a fixed alert id because the scanner can fire quickly
during games. Reusing the id makes the newest scan replace the previous
barcode popup in AlertFeed's keyed visible map instead of building a
stack of old scan cards that would hide the current wiki link.
*/
pushAlert({
id: 'barcode-scan-active',
kind: 'barcode-scan',
lifetimeMs: BARCODE_SCAN_LIFETIME_MS,
payload: {
...payload,
label,
},
});
}
socket.on('barcode:scanned', onBarcodeScanned);
return () => {
socket.off('barcode:scanned', onBarcodeScanned);
};
}, [pushAlert, socket]);
const latest = useMemo(
() =>
alerts.slice(-RECENT_ALERT_LOOKBACK).map((alert) => {
const key = buildKey(alert);
return {
alert,
key,
token: buildAlertToken(alert, key),
};
}),
[alerts],
);
useEffect(() => {
/*
The processed-token set is captured before scheduling the state update
because React may run the functional updater after this effect continues.
Reading latestTokensRef.current inside the updater would race with the ref
assignment below and could make brand-new alerts look already processed,
leaving the local toast list empty.
*/
const previousTokens = latestTokensRef.current;
const newItems = latest.filter((item) => !previousTokens.has(item.token));
if (newItems.length) {
setToasts((currentToasts) => {
const nextByKey = new Map(currentToasts.map((toast) => [toast.key, toast]));
newItems.forEach((item) => {
/*
Setting an existing Map key updates its value but keeps its original
insertion slot. Deleting first makes refreshed keyed alerts behave
like new notifications visually, which is what operators expect when
the barcode lane or a button-box lane receives a fresh event.
*/
nextByKey.delete(item.key);
nextByKey.set(item.key, buildToastRecord(item));
});
/*
The newest records are the only ones that should consume feed slots.
Older active records are dropped immediately so a burst of alerts
cannot cover the rest of the rover interface.
*/
const nextToasts = Array.from(nextByKey.values());
const activeKeys = nextToasts.slice(-MAX_VISIBLE_TOASTS).map((toast) => toast.key);
const activeKeySet = new Set(activeKeys);
return nextToasts.filter((toast) => activeKeySet.has(toast.key));
});
newItems.forEach((item) => {
scheduleToastRemoval(item.key, getAlertLifetime(item.alert));
});
}
latestTokensRef.current = new Set(latest.map((item) => item.token));
}, [latest, scheduleToastRemoval]);
const pauseToast = useCallback((key) => {
const toast = toasts.find((entry) => entry.key === key);
if (!toast || toast.paused) return;
const remainingMs = Math.max(0, toast.expiresAt - Date.now());
clearToastTimer(key);
setToasts((currentToasts) =>
currentToasts.map((toast) => {
if (toast.key !== key || toast.paused) return toast;
/*
Remaining time is captured at the moment interaction begins, not at the
previous render. That makes hover/focus pauses exact enough that a
toast cannot expire while the user is moving toward a link or dismiss
button inside it.
*/
return {
...toast,
paused: true,
remainingMs,
};
}),
);
}, [clearToastTimer, toasts]);
const resumeToast = useCallback((key) => {
const toast = toasts.find((entry) => entry.key === key);
if (!toast || !toast.paused) return;
const startedAt = Date.now();
const remainingMs = Math.max(0, toast.remainingMs);
setToasts((currentToasts) =>
currentToasts.map((toast) => {
if (toast.key !== key || !toast.paused) return toast;
return {
...toast,
paused: false,
expiresAt: startedAt + remainingMs,
remainingMs,
};
}),
);
scheduleToastRemoval(key, remainingMs);
}, [scheduleToastRemoval, toasts]);
const dismissToast = useCallback((key) => {
removeToast(key);
}, [removeToast]);
if (!toasts.length) return null;
const containerStyle =
scale === 1
? undefined
: {
transform: `translateX(-50%) scale(${scale})`,
transformOrigin: 'top center',
};
const containerClass =
scale === 1
? 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5'
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
return (
<div className={containerClass} style={containerStyle} aria-live="polite">
{toasts.map((toast) => (
<AlertToastShell
key={toast.key}
toast={toast}
onDismiss={dismissToast}
onPause={pauseToast}
onResume={resumeToast}
/>
<div
className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex w-max max-w-[80vw] flex-col items-center gap-0.5"
style={{ transform: `translateX(-50%) scale(${scale})`, transformOrigin: 'top center' }}
aria-live="polite"
>
{toasts.map((alert) => (
<AlertToast key={getAlertKey(alert)} alert={alert} opacity={opacity} onDismiss={dismiss} />
))}
</div>
);
}
function hexToRgb(hex) {
const safe = typeof hex === 'string' ? hex.trim() : '';
const match = /^#?([0-9a-fA-F]{6})$/.exec(safe);
if (!match) return null;
const value = match[1];
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function AlertToast({ alert, opacity, onDismiss }) {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const remainingRef = useRef(0);
const paused = hovered || focused;
function getSafeWikiUrl(rawUrl) {
const text = String(rawUrl || '').trim();
if (!text) return '';
useEffect(() => {
remainingRef.current = Number.isFinite(alert.lifetimeMs) ? alert.lifetimeMs : 3000;
}, [alert]);
try {
/*
Wiki links are operator-authored registry data, but this still normalizes
them before rendering an anchor. Allowing only http/https avoids turning a
barcode registry typo into a javascript: link while still supporting both
absolute URLs and local wiki paths such as /wiki/object-name.
*/
const parsed = new URL(text, window.location.origin);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.href;
} catch {
return '';
}
}
function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
const accent = getToastAccent(toast.alert);
const generic = isGenericAlert(toast.alert);
const rgb = getToastRgb(toast.alert);
const genericBackgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
const genericBorderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
const shellClass = [
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-sm shadow-black/40',
generic ? '' : 'border border-white/10 bg-black/75',
].join(' ');
useEffect(() => {
if (paused) return;
const startedAt = Date.now();
const timer = setTimeout(() => onDismiss(alert), Math.max(0, remainingRef.current));
return () => {
clearTimeout(timer);
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
};
}, [alert, paused, onDismiss]);
return (
<div
className={shellClass}
onMouseEnter={() => onPause(toast.key)}
onMouseLeave={() => onResume(toast.key)}
onFocusCapture={() => onPause(toast.key)}
onBlurCapture={() => onResume(toast.key)}
style={
generic
? {
/*
Generic alerts keep the same color math they used before the shared
shell was introduced. Putting it on the shell restores the original
brightness while still leaving room for the side dismiss column.
*/
backgroundColor: genericBackgroundColor,
border: `1px solid ${genericBorderColor}`,
}
: undefined
}
className="pointer-events-auto w-max max-w-full"
style={{ opacity }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={(event) => {
// Moving focus between controls inside the toast must not resume expiry.
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
}}
>
<div className="flex min-w-0 items-stretch">
<div className="min-w-0 flex-1">
<AlertToast alert={toast.alert} />
</div>
<div
className={[
'flex w-5 shrink-0 flex-col items-center justify-start gap-0.5 border-l py-0.5',
generic ? '' : 'border-white/10 bg-black/20',
].join(' ')}
style={
generic
? {
borderLeftColor: genericBorderColor,
backgroundColor: 'rgba(0, 0, 0, 0.08)',
}
: undefined
}
>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-sm text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-none focus:ring-1"
style={{ '--tw-ring-color': accent }}
onClick={() => onDismiss(toast.key)}
title="Dismiss alert"
aria-label="Dismiss alert"
>
×
</button>
</div>
</div>
</div>
);
}
function BarcodeScanToast({ payload }) {
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
const label = payload?.label || payload?.code || 'unknown barcode';
return (
<div className="pointer-events-auto max-w-[80vw] rounded-md bg-neutral-800 px-2 py-1 text-l leading-tight text-slate-100">
<style>
{`
@keyframes barcode-wiki-flat-flash {
0%, 100% {
background: rgb(233, 186, 14);
color: rgb(71, 144, 253);
}
50% {
background: rgb(125, 49, 255);
color: rgb(246, 250, 211);
}
}
`}
</style>
<p className="flex flex-wrap items-center gap-0.5 text-slate-100">
<span className="font-semibold text-slate-300 pr-1">Barcode scanned:</span>
<span className="font-semibold text-white">{label}</span>
{wikiUrl ? (
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="rounded-md ml-0.5 px-2 py-1 text-xl font-semibold leading-tight tracking-normal"
style={{ animation: 'barcode-wiki-flat-flash 0.35s steps(1, end) infinite' }}
>
Wiki link
</a>
) : null}
</p>
</div>
);
}
function UndockedExitWarningToast() {
return (
<div
/*
This warning deliberately keeps AlertFeed's existing dark shell and
compact rounded geometry while giving a safety-critical instruction
enough type size and breathing room to be unmistakable. The responsive
width leaves space for the feed shell's separate dismiss column on
narrow screens instead of allowing the card to overflow the viewport.
*/
className="w-[min(32rem,calc(80vw-1.25rem))] border-l-4 border-amber-400 bg-amber-950/90 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
function AlertToast({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
return (
<div className="pointer-events-none w-[12.5rem]">
<ButtonBoxTile
buttonId={payload.buttonId}
count={payload.count}
goal={payload.goal}
rewardNumber={payload.rewardNumber}
rewardName={payload.rewardName}
rewardDescription={payload.rewardDescription}
dailyCount={payload.dailyCount}
dailyLimit={payload.dailyLimit}
limited={payload.limited}
className={payload.limited ? 'bg-red-950/70 ring-1 ring-red-500/70' : 'bg-cyan-900/45'}
/>
{payload.description ? (
<p className={['mt-1 text-center text-xs font-semibold', payload.limited ? 'text-red-200' : 'text-cyan-100'].join(' ')}>
{payload.description}
</p>
) : null}
</div>
);
}
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
if (alert.kind === 'chat-typing' && alert.payload) {
return <ChatMessageRow message={alert.payload} variant="typing" />;
}
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
return (
<div
/*
Generic alerts are the payloads that directly provide title/message/color
instead of a richer custom renderer. The surrounding shell owns their
color-tinted background so the dismiss column and content share one
continuous surface while this renderer stays responsible for one line of
compact title/message text.
*/
className="pointer-events-auto min-w-0 max-w-[24rem] px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
>
<p className="truncate text-slate-100">
<span className="font-semibold text-slate-300">{alert.title || 'Alert'}</span>
<span className="text-slate-500"> · </span>
<span className="text-white">{alert.message}</span>
</p>
<AlertContent alert={alert} />
</div>
);
}
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx';
const BUTTONBOX_LIFETIME_MS = 5000;
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
export default function useAlertEvents() {
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons);
const { pushAlert } = useSessionActions();
const socket = useSocket();
useEffect(() => {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
/*
Event fields win over session fields because the button-box event is the
exact outcome of this press. Session sync follows immediately after, but
using it first can make fast toasts briefly show stale count/limit data.
*/
const goal = Number.isFinite(payload.goal) ? payload.goal : Number.isFinite(button.goal) ? button.goal : 0;
const dailyCount = Number.isFinite(payload.dailyCount)
? payload.dailyCount
: Number.isFinite(button.dailyCount)
? button.dailyCount
: 0;
const dailyLimit = Number.isFinite(payload.dailyLimit)
? payload.dailyLimit
: Number.isFinite(button.dailyLimit)
? button.dailyLimit
: null;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const rewardName =
typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
const rewardDescription =
typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
? button.rewardDescription.trim()
: null;
const description =
typeof payload.description === 'string' && payload.description.trim()
? payload.description.trim()
: null;
pushAlert({
id: `buttonbox-active-${buttonId}`,
kind: 'buttonbox-active',
lifetimeMs: BUTTONBOX_LIFETIME_MS,
payload: {
buttonId,
count,
goal,
dailyCount,
dailyLimit,
limited: Boolean(payload.limited),
description,
rewardNumber,
rewardName,
rewardDescription,
},
});
}
socket.on('buttonBox:increment', onButtonIncrement);
return () => {
socket.off('buttonBox:increment', onButtonIncrement);
};
}, [pushAlert, buttonBoxButtons, socket]);
useEffect(() => {
function onBarcodeScanned(payload = {}) {
const label = typeof payload.label === 'string' && payload.label.trim()
? payload.label.trim()
: payload.code || 'unknown barcode';
/*
Barcode scans use a fixed alert id because the scanner can fire quickly
during games. Reusing the id makes the newest scan replace the previous
barcode popup in AlertFeed's keyed visible map instead of building a
stack of old scan cards that would hide the current wiki link.
*/
pushAlert({
id: 'barcode-scan-active',
kind: 'barcode-scan',
lifetimeMs: BARCODE_SCAN_LIFETIME_MS,
payload: {
...payload,
label,
},
});
}
socket.on('barcode:scanned', onBarcodeScanned);
return () => {
socket.off('barcode:scanned', onBarcodeScanned);
};
}, [pushAlert, socket]);
}
@@ -57,7 +57,7 @@ function centerOfPressure(corners) {
function CornerReading({ className, label, value }) {
return (
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
<div className={`surface absolute min-w-22 text-center ${className}`}>
<div className="text-[0.62rem] text-slate-400">{label}</div>
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
@@ -83,7 +83,7 @@ function GameChoice({ game, disabled, onVote }) {
type="button"
disabled={disabled || game.active}
onClick={() => onVote(game.id)}
className="surface flex min-h-[3.5rem] flex-col items-start justify-between border-l-4 border-l-neutral-600 px-1 py-0.75 text-left transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-70"
className="surface flex min-h-14 flex-col items-start justify-between border-l-4 border-l-neutral-600 px-1 py-0.75 text-left transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-70"
style={style}
>
<span className="flex w-full items-start justify-between gap-1">
@@ -154,7 +154,7 @@ function DisplaySections({ sections }) {
{section.items.slice(0, 8).map((item, itemIndex) => (
<p
key={`${item.text}-${itemIndex}`}
className={`break-words text-xs leading-snug ${
className={`wrap-break-word text-xs leading-snug ${
item.status === 'active'
? 'font-semibold text-neutral-50'
: item.status === 'complete'
@@ -302,14 +302,14 @@ function BarcodeGamesPanelContent() {
fit its fixed timer and participant blocks without crushing the title. */}
<div className="grid items-stretch gap-0.5 @[28rem]:grid-cols-[minmax(0,1fr)_9rem_10rem]">
<div className="surface min-w-0 border-l-4 border-neutral-700 px-1 py-0.75" style={activeTheme.titleBoxStyle}>
<p className="break-words text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
<p className="wrap-break-word text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
{display.title || activeGame?.title || 'No game active'}
</p>
<p className="mt-0.5 break-words text-sm font-semibold leading-tight text-neutral-200">
<p className="mt-0.5 wrap-break-word text-sm font-semibold leading-tight text-neutral-200">
{display.primary || 'Vote for a game to start'}
</p>
{display.secondary ? (
<p className="mt-0.5 break-words text-xs leading-snug text-neutral-300">{display.secondary}</p>
<p className="mt-0.5 wrap-break-word text-xs leading-snug text-neutral-300">{display.secondary}</p>
) : null}
<DisplaySections sections={display.sections} />
</div>
+2 -2
View File
@@ -96,7 +96,7 @@ function BatteryBar({
}
const barShellClass = classNames(
'relative overflow-hidden rounded-sm border border-slate-800',
'relative overflow-hidden rounded-xs border border-slate-800',
isUrgent || isWarn ? baseToneStrong : 'bg-zinc-900/90',
isUrgent && 'battery-urgent-flash',
isVertical
@@ -132,7 +132,7 @@ function BatteryBar({
isVertical ? (compact ? 'text-[0.55rem]' : 'text-[0.65rem]') : 'text-xs',
);
const labelPillClass = classNames(
'rounded px-1.5 py-0.5',
'rounded-sm px-1.5 py-0.5',
isBackground ? 'bg-black/30 text-slate-100' : 'bg-black/50 text-slate-100',
);
@@ -10,7 +10,7 @@ function formatDegrees(value) {
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">{label}</span>;
return <span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">{label}</span>;
}
export default function CameraTiltControl({
@@ -147,7 +147,7 @@ export default function CameraTiltControl({
<div className={`flex items-center justify-between ${labelRowClass}`.trim()}>
<span className={labelClass}>{label}</span>
{showValue ? (
<span className={`rounded bg-slate-800 px-1 py-0.5 text-[0.7rem] font-semibold ${valueClass}`.trim()}>
<span className={`rounded-sm bg-slate-800 px-1 py-0.5 text-[0.7rem] font-semibold ${valueClass}`.trim()}>
{formatDegrees(value)}
</span>
) : null}
+2 -2
View File
@@ -82,7 +82,7 @@ export default function CardFrame({
// sidebars. Making the card itself a query container lets its contents
// respond to the space they actually receive instead of the viewport,
// which may be wide while a sidebar card is only a few hundred pixels.
'panel-section @container border border-neutral-500/60 bg-neutral-900/100',
'panel-section @container border border-neutral-500/60 bg-neutral-900',
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
fillHeight && 'flex h-full min-h-0 flex-col',
className,
@@ -99,7 +99,7 @@ export default function CardFrame({
// on the shared component gives long cards a consistent title bar
// without changing the layout of existing callers.
stickyHeader && 'sticky top-0 z-10',
// 'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
// 'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-linear-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
headerClassName,
)}
style={headerStyle}
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { find } from 'linkifyjs';
import ReactPlayer from 'react-player';
function ChatEmbed({ url }) {
const [failed, setFailed] = useState(false);
if (failed) return null;
// Only direct image paths are identifiable without fetching page metadata.
const isImage = /\.(avif|gif|jpe?g|png|webp|svg)$/i.test(new URL(url).pathname);
if (isImage) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="block max-w-full">
<img
src={url}
alt="Shared image"
loading="lazy"
className="max-h-60 max-w-full rounded-sm object-contain"
onError={() => setFailed(true)}
/>
</a>
);
}
if (!ReactPlayer.canPlay(url)) return null;
return (
<div className="aspect-video w-full max-w-sm overflow-hidden rounded-sm">
<ReactPlayer
src={url}
controls
playing={false}
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</div>
);
}
export default function ChatEmbeds({ text }) {
const urls = [...new Set(find(String(text || ''), 'url')
.map((link) => link.href)
.filter((url) => /^https?:\/\//i.test(url)))];
return urls.map((url) => <ChatEmbed key={url} url={url} />);
}
+19 -8
View File
@@ -3,8 +3,16 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useState } from 'react';
import { FaDiscord } from 'react-icons/fa';
import Linkify from 'linkify-react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import RoverLabel from '../RoverLabel/index.jsx';
import ChatEmbeds from './ChatEmbeds.jsx';
const LINK_OPTIONS = {
target: '_blank',
rel: 'noopener noreferrer',
className: 'text-sky-300 underline break-all',
};
function roleColors(role) {
switch (role) {
@@ -37,7 +45,7 @@ function displayName(message) {
// height, while the matching calc() size keeps the avatar square after adding that
// top/bottom coverage.
const CHAT_ROW_FULL_HEIGHT_AVATAR_CLASS =
'my-[-0.125rem] flex h-[calc(1rem+0.25rem)] w-[calc(1rem+0.25rem)] shrink-0 overflow-hidden rounded-none';
'-my-0.5 flex h-5 w-5 shrink-0 overflow-hidden rounded-none';
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
@@ -120,7 +128,7 @@ export function ChatIdentity({ message, toolsToggle = null }) {
/>
) : null}
{isBot ? (
<span className="shrink-0 rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
<span className="shrink-0 rounded-sm bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
bot
</span>
) : null}
@@ -185,7 +193,7 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
hasToolCalls ? (
<button
type="button"
className="shrink-0 rounded border border-slate-600/70 bg-slate-800/60 px-1 py-[1px] text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
className="shrink-0 rounded-sm border border-slate-600/70 bg-slate-800/60 px-1 py-px text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
onClick={() => {
if (isSpectator) return;
setOpen((v) => !v);
@@ -198,10 +206,12 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
<div className={chatRowClass(message, variant)}>
<div className="flex w-full items-start gap-0.5">
<span
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
className={`min-w-0 flex-1 wrap-break-word leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
>
<ChatIdentity message={message} toolsToggle={toolsToggle} />
{!isOpen && (hasText || isTyping) ? ` ${rowText}` : ''}
{!isOpen && (hasText || isTyping) ? (
<Linkify options={LINK_OPTIONS}>{` ${rowText}`}</Linkify>
) : null}
</span>
{!isTyping ? (
<span className="shrink-0 text-[0.65rem] text-slate-400/60">
@@ -210,7 +220,7 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
) : null}
</div>
{isOpen && hasToolCalls ? (
<div className="w-full rounded border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
<div className="w-full rounded-sm border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
{toolCalls.map((entry, idx) => {
const status = String(entry?.status || 'unknown');
const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown';
@@ -225,10 +235,11 @@ export default function ChatMessageRow({ message, variant = 'message' }) {
</div>
) : null}
{isOpen && hasText ? (
<div className={`w-full break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
{message.text}
<div className={`w-full wrap-break-word leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
<Linkify options={LINK_OPTIONS}>{message.text}</Linkify>
</div>
) : null}
{!isTyping && hasText ? <ChatEmbeds text={message.text} /> : null}
</div>
);
}
+5 -5
View File
@@ -159,7 +159,7 @@ function TtsControls({
: voice;
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
}}
className={`${compactSelectClass} w-[5.5rem] shrink`}
className={`${compactSelectClass} w-22 shrink`}
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
@@ -173,7 +173,7 @@ function TtsControls({
const next = event.target.value;
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className={`${compactSelectClass} w-[3.25rem] shrink`}
className={`${compactSelectClass} w-13 shrink`}
>
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
<option key={v} value={v}>
@@ -189,7 +189,7 @@ function TtsControls({
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
className={`${compactSelectClass} w-20 shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`pitch-${value}`} value={value}>
@@ -203,7 +203,7 @@ function TtsControls({
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
}}
className={`${compactSelectClass} w-[5rem] shrink`}
className={`${compactSelectClass} w-20 shrink`}
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`speed-${value}`} value={value}>
@@ -221,7 +221,7 @@ function TtsControls({
const next = Number(event.target.value);
saveTtsSettings((current) => ({ ...(current || {}), pitch: next }));
}}
className={`${compactSelectClass} w-[4.5rem] shrink`}
className={`${compactSelectClass} w-18 shrink`}
>
{ESPEAK_PITCHES.map((p) => (
<option key={p} value={p}>
@@ -31,7 +31,7 @@ function StatusRow({ value, tone = 'neutral' }) {
function KeyPill({ label }) {
if (!label) return null;
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
return <span className="rounded-sm border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
}
function ActionPill({ label, tone }) {
@@ -186,7 +186,7 @@ export default function DriveDockAction({
// tapped repeatedly; attach mobile touch suppression directly to the card so
// Safari does not select labels or open callouts during those interactions.
const baseCardClasses =
'mobile-touch-control flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
'mobile-touch-control flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-0.75 py-0.75 text-slate-100 shadow-md transition hover:-translate-y-0.5 hover:shadow-xl focus-visible:outline-hidden focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 select-none no-touch-select';
const ctaText = 'text-center';
const ctaLayout = 'items-center justify-between';
const compactLayout = 'items-center justify-center';
@@ -352,9 +352,9 @@ function StepList({ steps, tone = 'emerald' }) {
? 'text-amber-200'
: 'text-indigo-200';
return (
<div className={`space-y-0.5 rounded border px-0.5 py-0.35 text-left ${container}`}>
<div className={`space-y-0.5 rounded-sm border px-0.5 py-0.35 text-left ${container}`}>
{steps.map((step, idx) => (
<div key={step} className="text-[0.85rem] leading-snug break-words">
<div key={step} className="text-[0.85rem] leading-snug wrap-break-word">
<div className="flex items-start">
<span className={`mr-0 align-top text-[0.75rem] font-semibold ${numberColor}`}>{idx + 1}.</span>
<span className="align-top">{step}</span>
+2 -2
View File
@@ -23,7 +23,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
<CardFrame hideHeader className="shrink-0">
{/* The legacy page keeps its existing muted 4:3 frame while sharing
the removal-message behavior with the current video surface. */}
<div className="panel-muted aspect-[4/3]">
<div className="panel-muted aspect-4/3">
<EmptyDriverVideoNotice />
</div>
</CardFrame>
@@ -34,7 +34,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
return (
<CardFrame hideHeader className="shrink-0">
<div className="flex flex-col gap-0.5">
<div className="relative w-full overflow-hidden bg-black aspect-[4/3]">
<div className="relative w-full overflow-hidden bg-black aspect-4/3">
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
<TurnsOverlay mobileHud={mobileHud} />
<RoverDescriptionOverlay
@@ -16,7 +16,7 @@ export default function DuplicateIdentityOverlay() {
: 'This driver session is already active in another tab.';
return (
<div className="pointer-events-auto fixed inset-0 z-[2000] flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
<div className="pointer-events-auto fixed inset-0 z-2000 flex items-center justify-center bg-black px-0.5 py-0.5 text-slate-100">
<section className="surface w-full max-w-md space-y-0.5 text-center shadow-2xl">
<div className="space-y-0.5">
<h1 className="text-lg font-semibold text-white">Another driver tab is already open</h1>
@@ -24,13 +24,13 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
<p className="text-xs border-t border-b border-slate-700 p-0.5 text-blue-300 text-center">This will only show once just to let you know. There is a fullscreen button in the bottom right for later use.</p>
<div className="flex justify-end gap-0.5 pt-1 text-sm">
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
<button type="button" className="rounded-sm border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
{isIOSMode ? 'Got it' : 'Not now'}
</button>
{!isIOSMode && (
<button
type="button"
className="rounded bg-cyan-500 px-3 py-1 font-semibold text-black hover:bg-cyan-400"
className="rounded-sm bg-cyan-500 px-3 py-1 font-semibold text-black hover:bg-cyan-400"
onClick={onEnterFullscreen}
>
Enter fullscreen
@@ -131,12 +131,12 @@ export default function GPIOToggleControl({
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span className="text-sm font-semibold">{label}</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
<span className="rounded-sm bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
{keyLabel}
</span>
) : null}
</span>
<span className={`rounded px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
<span className={`rounded-sm px-1 py-0.5 text-[0.7rem] font-semibold ${statusClasses}`}>
{statusLabel}
</span>
</button>
@@ -48,13 +48,13 @@ function actionDriveMode(actionId) {
function CurveField({ label, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="linear">Linear</option>
<option value="expo">Fine center control</option>
@@ -78,7 +78,7 @@ function MappingRow({
// Mapping rows are constrained to a readable width so the source text and buttons remain
// visually connected. Buttons wrap on very narrow panes instead of forcing tiny text.
return (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[520px]:grid-cols-1">
<div className="min-w-0">
<div className="flex items-center gap-1">
<p className="font-semibold leading-snug text-white">{action.label}</p>
@@ -471,7 +471,7 @@ export default function GamepadMappingSettings() {
bodyClassName="space-y-2 p-1 text-sm"
>
{captureAction && (
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
<div className="mx-auto flex w-full max-w-lg items-center justify-between gap-1 rounded-sm bg-emerald-950/50 px-1.5 py-1 text-sm text-white">
<span>Release controls, then move or press the input for {captureAction.label}.</span>
<button type="button" onClick={() => setCaptureAction(null)} className="button-dark px-1 py-0.5 text-xs">
Cancel
@@ -482,7 +482,7 @@ export default function GamepadMappingSettings() {
<div className="space-y-1">
<SettingsGroupLabel>Connected controller</SettingsGroupLabel>
{hubState.error ? (
<p className="mx-auto w-full max-w-lg rounded border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
<p className="mx-auto w-full max-w-lg rounded-sm border border-red-500/60 bg-red-950/40 px-1.5 py-1 text-sm text-white">
Controller access failed: {hubState.error}
</p>
) : hubState.pads.length === 0 ? (
@@ -492,11 +492,11 @@ export default function GamepadMappingSettings() {
: 'No controller detected. Connect it, focus this page, then press a button.'}
</p>
) : (
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<div className="mx-auto grid w-full max-w-lg grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 rounded-sm bg-neutral-800/80 px-1.5 py-1 text-sm max-[420px]:grid-cols-1">
<select
value={activePad?.instanceKey ?? ''}
onChange={(event) => setActiveInstanceKey(event.target.value)}
className="min-w-0 rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="min-w-0 rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
{hubState.pads.map((pad) => (
<option key={pad.instanceKey} value={pad.instanceKey}>
@@ -504,7 +504,7 @@ export default function GamepadMappingSettings() {
</option>
))}
</select>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 text-xs text-white">
{activePad?.mapping ?? 'unknown'}
</span>
<p className="col-span-full truncate text-xs text-slate-300" title={activePad?.id}>
@@ -519,13 +519,13 @@ export default function GamepadMappingSettings() {
{/* Calibration controls stay in one stacked column because range inputs become harder to
tune when squeezed into multiple narrow columns. */}
<div className="grid gap-1">
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5 text-sm text-white">
<span className="font-semibold">Button prompts</span>
<select
value={activeProfile.promptStyle ?? 'auto'}
onChange={(event) => updateProfile({ promptStyle: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="auto">Automatic</option>
<option value="xbox">Xbox</option>
@@ -536,7 +536,7 @@ export default function GamepadMappingSettings() {
</div>
<p className="mt-0.5 text-xs leading-snug text-white">Override this only when the browser reports the controller incorrectly.</p>
</label>
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">Steering mode</span>
<select
@@ -545,7 +545,7 @@ export default function GamepadMappingSettings() {
updateCalibration({ driveMode: event.target.value });
setCaptureAction(null);
}}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="single">Single stick</option>
<option value="tank">Tank sticks</option>
@@ -641,7 +641,7 @@ export default function GamepadMappingSettings() {
onChange={(value) => updateCalibration({ precisionSpeed: value })}
/>
{driveMode === 'single' && (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
{/* Camera mode is styled like the sliders so calibration controls read as one group
even though this specific setting is a select instead of a range input. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
@@ -649,7 +649,7 @@ export default function GamepadMappingSettings() {
<select
value={activeProfile.calibration?.cameraMode ?? 'velocity'}
onChange={(event) => updateCalibration({ cameraMode: event.target.value })}
className="rounded border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
className="rounded-sm border border-neutral-600 bg-neutral-900 px-1 py-0.5 text-sm text-white"
>
<option value="absolute">Absolute</option>
<option value="velocity">Velocity</option>
@@ -714,7 +714,7 @@ export default function GamepadMappingSettings() {
{!activePad ? (
null
) : (
<details className="mx-auto w-full max-w-lg rounded bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<details className="mx-auto w-full max-w-lg rounded-sm bg-neutral-900/70 px-1.5 py-1 text-xs text-white">
<summary className="cursor-pointer text-sm font-semibold text-white">Advanced diagnostics</summary>
<div className="mt-1 space-y-1">
<p className="text-white">Raw axes</p>
@@ -5,12 +5,12 @@ import { NUMBER_FORMAT } from './constants.js';
export default function SliderField({ label, description, min, max, step, value, onChange }) {
return (
<label className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1">
<label className="mx-auto block w-full max-w-lg rounded-sm bg-neutral-800/80 px-1.5 py-1">
{/* The formatted value is kept close to the setting name because calibration usually
happens by nudging a slider and watching the number, not by reading the full panel row. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-1.5 text-sm text-white">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 font-mono text-xs text-white">
<span className="rounded-sm bg-neutral-900 px-1 py-0.5 font-mono text-xs text-white">
{NUMBER_FORMAT.format(value)}
</span>
</div>
@@ -110,7 +110,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
[
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
!isMobile ? '@container' : '',
isMobile ? 'rounded-none' : 'rounded',
isMobile ? 'rounded-none' : 'rounded-sm',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
]
@@ -163,7 +163,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
className={
isMobile
? 'flex-1 overflow-hidden text-slate-100'
: 'flex-1 whitespace-normal break-words text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
: 'flex-1 whitespace-normal wrap-break-word text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
}
>
<span ref={textRef} className="block">
@@ -8,7 +8,7 @@ import { getHelpContent } from '../../help/content.js';
function KeyPill({ actionId }) {
return (
<span className="rounded border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
<span className="rounded-sm border border-slate-600 bg-slate-900/40 px-1 text-[0.7rem] text-slate-200">
<ControlHint actionId={actionId} />
</span>
);
@@ -52,7 +52,7 @@ function Hero({ hero, keymap }) {
{hero.chips && (
<div className="flex flex-wrap gap-0.5 text-[0.7rem] text-slate-200">
{hero.chips.map((chip) => (
<span key={chip} className="rounded border border-slate-700 px-1 py-[2px]">
<span key={chip} className="rounded-sm border border-slate-700 px-1 py-[2px]">
{chip}
</span>
))}
@@ -19,7 +19,7 @@ export default function NumberControl({ entity, disabled, onChange }) {
<input type="number" aria-label={entity.name} min={entity.min ?? undefined} max={entity.max ?? undefined} step={entity.step || 'any'}
value={value} disabled={blocked} onChange={(event) => control.edit(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') control.commit(event.currentTarget.value); }}
className="w-16 min-w-0 rounded border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
className="w-16 min-w-0 rounded-sm border border-neutral-700 bg-neutral-950 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-50" />
{entity.unit ? <span className="text-[0.65rem] text-slate-400">{entity.unit}</span> : null}
</div>
{!limitsKnown ? <span className="text-xs text-amber-200">Waiting for number limits</span> : null}
@@ -1,6 +1,6 @@
// Preserve raw sensor text and units; a sensor's non-on value is never "off".
export default function ReadOnlyControl({ entity }) {
return <span className="break-words text-xs text-slate-200">
return <span className="wrap-break-word text-xs text-slate-200">
{entity.available ? (entity.password ? '••••••' : `${entity.state}${entity.unit ? ` ${entity.unit}` : ''}`) : 'Unavailable'}
</span>;
}

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