mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 11:12:16 -05:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4af2ebb3 | ||
|
|
b2bbaf2085 | ||
|
|
18e7fb8d1b | ||
|
|
ba71106544 | ||
|
|
a395656056 | ||
|
|
50c2c1f5db | ||
|
|
b59389870e | ||
|
|
cd4aee4e74 | ||
|
|
9de0bfb1e7 | ||
|
|
008c90edf1 | ||
|
|
eed88983ca | ||
|
|
923c04736b | ||
|
|
bdc3eb640d | ||
|
|
01354c56e6 | ||
|
|
0ff03a74a5 | ||
|
|
73a78eef20 | ||
|
|
a157ece816 | ||
|
|
8cb7cc5bdc | ||
|
|
dbcb7f6a7a | ||
|
|
34936ab05c | ||
|
|
7231809140 | ||
|
|
8ca714cf1a | ||
|
|
ffd34de56c | ||
|
|
aeeff6fe1b | ||
|
|
846c9dae55 | ||
|
|
e750712a39 | ||
|
|
ff8b0c530e | ||
|
|
69c5bfe544 | ||
|
|
3ad95b376a | ||
|
|
bda740b08d | ||
|
|
3c8c5f5883 | ||
|
|
c35544e515 | ||
|
|
0df4a5ec89 | ||
|
|
8e301e7db7 | ||
|
|
b712fb89c6 | ||
|
|
fe2c29371f | ||
|
|
61ee692815 | ||
|
|
487666eed8 | ||
|
|
51b42ea778 | ||
|
|
318cad1846 | ||
|
|
f3e3b6a803 | ||
|
|
b48348d89e |
+10
@@ -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 d -exec chmod 0755 {} + \
|
||||||
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
|
&& 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
|
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||||
ARG FEDORA_VERSION
|
ARG FEDORA_VERSION
|
||||||
ARG TARGETARCH
|
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/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
||||||
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||||
COPY --from=packaged-tools /output/ /
|
COPY --from=packaged-tools /output/ /
|
||||||
|
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/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
|
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -0,0 +1,964 @@
|
|||||||
|
# Rover learning service implementation tracker
|
||||||
|
|
||||||
|
## Resume here
|
||||||
|
|
||||||
|
Latest change (2026-09-29 IPI): format 4 seeds known user-control command shapes
|
||||||
|
before training. Generic peripherals are excluded; sensorStream is ignored without
|
||||||
|
invalidating footage. Drive includes zero-speed drive, with no separate stop type.
|
||||||
|
Drive, camera, brushes/vacuum, headlight, laser, horn, songs and operating modes
|
||||||
|
remain supported. Raw recordings and the existing checked command pipe stay intact.
|
||||||
|
|
||||||
|
Held-out data cannot expand the schema. Unknown control variants exclude nearby
|
||||||
|
windows only, with command/count/reason diagnostics and a coverage gate that blocks
|
||||||
|
promotion. Supported portions still receive evaluation. Current training lineage
|
||||||
|
is resume-v5.pt / schedule-v5.json. Startup removes obsolete format 1-3 models and
|
||||||
|
old lineages through v4; recordings and current-format models are preserved.
|
||||||
|
|
||||||
|
Verification for this increment is recorded at the end. Earlier version-specific
|
||||||
|
notes are historical. Manual inputs never pause/cancel autonomy; disabled service
|
||||||
|
remains dormant. Actual autonomous performance has not been established.
|
||||||
|
|
||||||
|
This document preserves the conversation's decisions across compactions and
|
||||||
|
usage cutoffs. Keep the checklist and handoff section current during authorized
|
||||||
|
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: see the command-event policy handoff at the end. Earlier
|
||||||
|
format 2 wheel-specific design notes below are historical and superseded.
|
||||||
|
|
||||||
|
Next work: verify the integrated feature against actual recordings and the real
|
||||||
|
camera/rover pipeline when that environment is available; inspect the VIP card in
|
||||||
|
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-v4.pt, schedule-v4.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.
|
||||||
|
|
||||||
|
|
||||||
|
### UI cleanup and obsolete models
|
||||||
|
|
||||||
|
User explicitly requested removal of UI narration and deletion of obsolete models.
|
||||||
|
Removed snapshot/threshold explanatory prose and the old commented manual-control
|
||||||
|
note; idle status now says Stopped. Kept operational status/errors and diagnostics.
|
||||||
|
Enabled-service startup now deletes permanent model directories whose validated
|
||||||
|
manifest explicitly identifies policy format 1, even when training is disabled.
|
||||||
|
Format 2 snapshots, Latest/Previous, recordings and current training state remain.
|
||||||
|
This supersedes earlier promises to preserve incompatible format 1 model files.
|
||||||
|
Deletion occurs on the actual server when the updated service starts; no production
|
||||||
|
files were accessed from this development machine.
|
||||||
|
|
||||||
|
|
||||||
|
### Recording maintenance race and diagnostic error details
|
||||||
|
|
||||||
|
Authorized by "fix it and fix that IPI" after a production report showed two
|
||||||
|
maintenance failures but omitted their error details. Scoped to those two defects;
|
||||||
|
evaluation/cache changes remain proposals, not part of this repair.
|
||||||
|
|
||||||
|
- listRecordings now tolerates ENOENT for directories/files removed between listing
|
||||||
|
and stat (dock-buffer pruning or atomic metadata rename). Other filesystem
|
||||||
|
errors still propagate; no broad error suppression or retries were added.
|
||||||
|
- Diagnostics reportVersion 2 includes actual redacted error messages, rejection
|
||||||
|
reasons, controller results, event detail, and worker stderr. Runtime catches
|
||||||
|
retain error name/code/syscall/path/stack, timestamp and operation. Recording
|
||||||
|
failures retain the same structured details through session finalization.
|
||||||
|
- A shared redactor removes URLs, credentials, user keys, email addresses and home
|
||||||
|
usernames while retaining filesystem operation context and stack frames. Error
|
||||||
|
categories remain supplementary, not replacements for actual failures.
|
||||||
|
- Node syntax checks passed for affected modules. No actual production recording
|
||||||
|
stream is available here; the race is fixed by inspection but cannot yet be
|
||||||
|
confirmed as the cause of the earlier production events. Previously discarded
|
||||||
|
error details cannot be reconstructed. No new tests or processes were created.
|
||||||
|
|
||||||
|
### Deliberate held-out selection
|
||||||
|
|
||||||
|
Latest IPI authorizes the scheduler correction only. Randomly shuffled eligible
|
||||||
|
sessions are now partitioned with exactly the worker's SHA256 first-eight-hex
|
||||||
|
modulo-five rule. Jobs select up to three training sessions and one held-out
|
||||||
|
session when both partitions exist; otherwise up to four training sessions.
|
||||||
|
No worker starts with held-out sessions alone. Existing split assignments, model
|
||||||
|
weights, and training progress are unchanged. Diagnostics expose timestamped
|
||||||
|
available/selected partition counts and the reason when evaluation cannot be
|
||||||
|
scheduled. Selection guarantees a held-out recording, not usable evaluation
|
||||||
|
windows: decoding/vocabulary/sample checks still apply in the worker.
|
||||||
|
Node syntax checks and diff whitespace checks passed; actual job selection on
|
||||||
|
production recordings remains unverified. No new tests or background processes.
|
||||||
|
|
||||||
|
### Active implementation: command-event policy (authorized IPI)
|
||||||
|
|
||||||
|
Supersedes format 2 continuous wheel output. The model predicts numeric command
|
||||||
|
scores and payload values; ordinary code constructs commands for the existing
|
||||||
|
shared command pipe. It does not generate JSON text. Drive, recorded key-release
|
||||||
|
stops, and accessories all use the threshold. Remove wheel-speed sensors and
|
||||||
|
previous drive-command inputs, but preserve raw recordings. Exact zero values
|
||||||
|
remain template constants so a demonstrated stop cannot regress into creeping.
|
||||||
|
|
||||||
|
Keep one continuing learner, Latest/Previous, admin snapshots, recording lifecycle,
|
||||||
|
permissions, and manual inputs that never cancel autonomy. No prescribed human
|
||||||
|
driving. Start a new format 3 lineage, reuse recordings, show older snapshots as
|
||||||
|
incompatible. No model-size increase or recording redesign in this change.
|
||||||
|
|
||||||
|
Update the upper card to show every command proposal with decoded parameters,
|
||||||
|
score, threshold marker and dispatch result, including below-threshold proposals.
|
||||||
|
Update copied diagnostics and evaluation for command events, parameter error,
|
||||||
|
missed stops and unwanted commands. Evaluate more than one held-out recording;
|
||||||
|
event validation is not proof of autonomous driving or physical rollout quality.
|
||||||
|
|
||||||
|
Progress: implemented; focused checks passed. User's unrelated webui/package.json
|
||||||
|
edit was preserved. No changes to runtime recording, disabled startup, control
|
||||||
|
permissions or manual-input override semantics.
|
||||||
|
|
||||||
|
Implementation details for resume:
|
||||||
|
- workers/policy.py VERSION=3, CNN/GRU unchanged in size; two heads for scores and
|
||||||
|
command parameters. Shared select_slots chooses one winner per command family
|
||||||
|
(peripheral id/control are separate families). No unconditional drive head.
|
||||||
|
- Numeric zeros and address fields stay exact template constants. Stop is the
|
||||||
|
normal recorded driveDirect {left:0,right:0} command. Unknown templates still
|
||||||
|
reject explicitly at the 64-shape limit. Arrays remain exact demonstrations.
|
||||||
|
- workers/dataset.py yields five arrays: images, state, event labels, parameters,
|
||||||
|
available shapes. It includes accepted human drive events, excludes ambiguous
|
||||||
|
conflicting events in the same 100ms window, and no longer requires an inferred
|
||||||
|
held wheel-speed target. Raw recorded sensors/commands are unchanged.
|
||||||
|
- Training reserves separate bounded samples for stop events, other commands and
|
||||||
|
no-command windows. Evaluation remains uniformly sampled. Scheduler selects
|
||||||
|
up to three training and two held-out recordings under the unchanged SHA split.
|
||||||
|
- Gate: complete evaluation, >=2 usable held-out sessions, >=128 windows, >=32
|
||||||
|
command events, >=8 stops, >=16 movement events; command precision/recall, stop
|
||||||
|
recall and movement recall each >=0.5; normalized numeric MSE <=0.1 where
|
||||||
|
applicable. Diagnostics expose each failing reason and per-command counts.
|
||||||
|
These are experimental screening thresholds, not a guarantee of useful driving.
|
||||||
|
- New optimizer/scheduler filenames prevent silently reusing format 2 weights or
|
||||||
|
old rejected-session bookkeeping. No config values need changing. Existing
|
||||||
|
permanent format 2 snapshots are preserved, but cannot start; attempting to
|
||||||
|
snapshot an old candidate reports that a new command-policy checkpoint is needed.
|
||||||
|
- Live inference keeps existing discrete toggle latching and common command limits.
|
||||||
|
The threshold applies to drive and stop just like the other command predictions.
|
||||||
|
No-event means send nothing: it DOES NOT implicitly stop an already moving rover.
|
||||||
|
It must learn explicit stop events; removing the copy shortcut is not proof that
|
||||||
|
learning will succeed. Other motion-related sensors still remain as observations.
|
||||||
|
- Upper card replaces separate wheel meters with generic scored proposals and
|
||||||
|
decoded parameters; shows applied threshold and post-clamp submitted parameters.
|
||||||
|
Copied diagnostics retain top eight proposals and up to 16 submissions per
|
||||||
|
sampled controller-history entry, including their command objects.
|
||||||
|
|
||||||
|
Verification: actual train.py --check-dependencies loaded Torch/NumPy/PyAV and
|
||||||
|
constructed format 3 (803,440 parameters); infer.py --help loaded successfully;
|
||||||
|
Node --check passed for six changed service modules; targeted card ESLint passed.
|
||||||
|
No new tests or throwaway programs. Live end-to-end training/inference and browser
|
||||||
|
visual verification require the actual environment and have not been performed.
|
||||||
|
|
||||||
|
Deferred explicitly: chronological commanded-motion replay/closed-loop evaluation,
|
||||||
|
prepared frame caching and larger architectures. Current evaluation uses held-out
|
||||||
|
100ms event windows with the live threshold/family selection, but cannot assess
|
||||||
|
temporal latching or accumulated motion from random reservoir samples. It does
|
||||||
|
not claim to verify actual physical command execution. After normal deployment,
|
||||||
|
let the new learner process retained recordings; copied reportVersion 3 diagnostics
|
||||||
|
provide the next evidence. Do not demand prescribed driving or a manual checklist.
|
||||||
|
|
||||||
|
### Obsolete model cleanup after format 3 transition
|
||||||
|
|
||||||
|
Latest user IPI explicitly authorizes deleting obsolete snapshots, checkpoints and
|
||||||
|
training state. This supersedes earlier format 2 preservation notes above.
|
||||||
|
Enabled-service startup, before trainer/driving/gateway startup, scans both models/
|
||||||
|
and training/checkpoints/. Identity-validated manifests with integer policy version
|
||||||
|
>=1 and < current POLICY_VERSION are removed. Current/future/unknown-format models
|
||||||
|
and recordings are preserved. Candidate/Latest/Previous references to obsolete
|
||||||
|
checkpoints are atomically removed before directory deletion; leased directories
|
||||||
|
are retired until release. Cleanup also removes legacy resume/schedule files and
|
||||||
|
temporary files for the unversioned and v1-v3 lineages. Current v4 state is kept.
|
||||||
|
Cleanup runs even when training is disabled, but not when the whole service is off.
|
||||||
|
Production deletion occurs on the next enabled-service startup after deployment.
|
||||||
|
Verification: focused Node syntax and diff whitespace checks passed. No production
|
||||||
|
storage was accessed; actual deletion awaits enabled-service startup on the server.
|
||||||
|
|
||||||
|
### User-control scope and evaluation coverage (2026-09-29)
|
||||||
|
|
||||||
|
Authorized by latest IPI after user clarified that controls include camera, brushes,
|
||||||
|
lights, laser, horn and songs but NOT generic peripherals. No new command pipe or
|
||||||
|
separate stop command. sensorStream is neither input/target nor a boundary. Actual
|
||||||
|
reset/reboot/update remain temporal boundaries because they interrupt observations.
|
||||||
|
|
||||||
|
Format 4 seeds drive and motor zero/nonzero field combinations, servo, light/laser
|
||||||
|
actions, the two four-frequency horn waveforms and horn release, OI start/safe/full/
|
||||||
|
dock bytes and default song. Seed values describe shapes, not artificial training
|
||||||
|
examples. Horn frequencies are numeric parameters; integral JSON numbers share
|
||||||
|
shapes with equivalent floating representations for drive/motors/servo/horn. Songs
|
||||||
|
remain exact demonstrated note arrays; other song/raw variants can extend the
|
||||||
|
training schema within its existing 64-slot bound. No promise to pre-enumerate all
|
||||||
|
possible tunes. Seeded slots receive negative examples even in sessions where that
|
||||||
|
control was not used, avoiding untrained random seeded outputs.
|
||||||
|
|
||||||
|
Evaluation freezes the training schema. Unsupported commands are reported with
|
||||||
|
payload, reason, occurrence count (up to 32 distinct descriptions), total count and
|
||||||
|
affected-window count. The affected observation history/target interval is excluded
|
||||||
|
without labeling the unknown command as inaction or skipping the entire recording.
|
||||||
|
Any unsupported held-out command blocks promotion; metrics describe only supported
|
||||||
|
windows. If none survive, dataset diagnostics still report the coverage problem.
|
||||||
|
Existing permission checks, threshold mechanics, recording and manual input behavior
|
||||||
|
are unchanged. Generic peripherals are also removed from execution allowlist and
|
||||||
|
rover compatibility profile. Previous policy models are automatically cleaned up;
|
||||||
|
no configuration edit is required.
|
||||||
|
|
||||||
|
Verification completed: train.py --check-dependencies loaded the production
|
||||||
|
worker and constructed format 4 with 803,440 parameters and predefined command
|
||||||
|
shapes. infer.py --help, Node syntax checks for the four changed service modules,
|
||||||
|
focused VipRoverLearningCard ESLint and diff whitespace checks passed.
|
||||||
|
No live server data/hardware/browser available locally. Actual training, driving,
|
||||||
|
and browser appearance remain unverified. No new tests or background processes.
|
||||||
|
Deploying starts a new learner using existing recordings; enabled-service startup
|
||||||
|
removes older policy models and optimizer/scheduler lineages through v4. Current
|
||||||
|
lineage is v5. No settings changes or prescribed human driving are required.
|
||||||
@@ -68,6 +68,7 @@ require('./src/services/batteryManager');
|
|||||||
// subscriptions see fully decoded state without becoming an initialization
|
// subscriptions see fully decoded state without becoming an initialization
|
||||||
// dependency of either control path.
|
// dependency of either control path.
|
||||||
require('./src/services/fleetReportService');
|
require('./src/services/fleetReportService');
|
||||||
|
require('./src/services/roverLearningService');
|
||||||
require('./src/services/replayEngineV2');
|
require('./src/services/replayEngineV2');
|
||||||
// Replay delivery is a core service. It must subscribe before the optional
|
// Replay delivery is a core service. It must subscribe before the optional
|
||||||
// Discord feature so web requests always have a local delivery path.
|
// Discord feature so web requests always have a local delivery path.
|
||||||
|
|||||||
+3
-3
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
@@ -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
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
import{r as g,R as a}from"./index-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
@@ -0,0 +1,2 @@
|
|||||||
|
import{r as g,R as a}from"./index-5EWtcenj.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
|
||||||
|
//# sourceMappingURL=Preview-CKTJqKe-.js.map
|
||||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -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};
|
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||||
//# sourceMappingURL=SetupApp-CaWv689i.js.map
|
//# sourceMappingURL=SetupApp-DN7XBkOe.js.map
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
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
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import{_ as N,R as A}from"./index-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
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import{R as W}from"./index-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
+23
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import{R as W}from"./index-5EWtcenj.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
video,
|
||||||
|
audio {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
|
||||||
|
<div class="wistia_embed"></div>
|
||||||
|
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 300px;
|
||||||
|
min-height: 150px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
::slotted(.wistia_embed) {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
|
||||||
|
//# sourceMappingURL=react-DxdYv-AY.js.map
|
||||||
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import{_ as N,R as A}from"./index-5EWtcenj.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
|
||||||
|
|
||||||
|
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
|
||||||
|
`,i+=`${e.text}
|
||||||
|
|
||||||
|
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
|
||||||
|
//# sourceMappingURL=react-gdx3v4jU.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics:inject -->
|
<!-- analytics:inject -->
|
||||||
<script type="module" crossorigin src="/assets/index-n0JxE1Mv.js"></script>
|
<script type="module" crossorigin src="/assets/index-dJH5nUWV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-xCRVLGjq.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DOmNOCX4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ test('service definitions generate public feature paths without a separate regis
|
|||||||
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
|
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
|
||||||
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
|
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
|
||||||
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
|
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
|
||||||
|
// Activities remain independently configurable from the shared connection.
|
||||||
|
{ key: 'homeAssistantActivities', path: ['homeAssistantActivities', 'enabled'] },
|
||||||
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
|
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
|
||||||
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
|
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
|
||||||
{ key: 'kinect', path: ['kinect', 'enabled'] },
|
{ key: 'kinect', path: ['kinect', 'enabled'] },
|
||||||
@@ -182,6 +184,7 @@ test('service definitions generate public feature paths without a separate regis
|
|||||||
{ key: 'discord', path: ['discord', 'enabled'] },
|
{ key: 'discord', path: ['discord', 'enabled'] },
|
||||||
{ key: 'socials', path: ['socials', 'enabled'] },
|
{ key: 'socials', path: ['socials', 'enabled'] },
|
||||||
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
||||||
|
{ key: 'roverLearning', path: ['roverLearning', 'enabled'] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const bandwidthSavings = require('../helpers/bandwidthSavings.configuration');
|
|||||||
const audioForward = require('../services/audioForwardService/configuration');
|
const audioForward = require('../services/audioForwardService/configuration');
|
||||||
const audioLevels = require('../services/audioLevelsService/configuration');
|
const audioLevels = require('../services/audioLevelsService/configuration');
|
||||||
const homeAssistant = require('../services/homeAssistantService/configuration');
|
const homeAssistant = require('../services/homeAssistantService/configuration');
|
||||||
|
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
|
||||||
const roomCameras = require('../services/roomCameraService/configuration');
|
const roomCameras = require('../services/roomCameraService/configuration');
|
||||||
const ptzCamera = require('../services/ptzCameraService/configuration');
|
const ptzCamera = require('../services/ptzCameraService/configuration');
|
||||||
const kinect = require('../services/kinectService/configuration');
|
const kinect = require('../services/kinectService/configuration');
|
||||||
@@ -21,6 +22,7 @@ const barcodeScanner = require('../services/barcodeScannerService/configuration'
|
|||||||
const commands = require('../services/operatorCommandService/configuration');
|
const commands = require('../services/operatorCommandService/configuration');
|
||||||
const discord = require('../services/discordBotService/configuration');
|
const discord = require('../services/discordBotService/configuration');
|
||||||
const fleetReports = require('../services/fleetReportService/configuration');
|
const fleetReports = require('../services/fleetReportService/configuration');
|
||||||
|
const roverLearning = require('../services/roverLearningService/configuration');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Object property order is preserved by JSON serialization and JSON Schema
|
Object property order is preserved by JSON serialization and JSON Schema
|
||||||
@@ -41,6 +43,8 @@ const definitions = [
|
|||||||
audioForward,
|
audioForward,
|
||||||
audioLevels,
|
audioLevels,
|
||||||
homeAssistant,
|
homeAssistant,
|
||||||
|
// Activities own a separate catalog even though the HA connection is shared.
|
||||||
|
homeAssistantActivities,
|
||||||
roomCameras,
|
roomCameras,
|
||||||
ptzCamera,
|
ptzCamera,
|
||||||
kinect,
|
kinect,
|
||||||
@@ -52,6 +56,7 @@ const definitions = [
|
|||||||
sessionConfiguration.socials,
|
sessionConfiguration.socials,
|
||||||
sessionConfiguration.driverAd,
|
sessionConfiguration.driverAd,
|
||||||
fleetReports,
|
fleetReports,
|
||||||
|
roverLearning,
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultConfig = Object.fromEntries(
|
const defaultConfig = Object.fromEntries(
|
||||||
|
|||||||
@@ -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) {
|
function applySchemaMigrations(db) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ module.exports = {
|
|||||||
goal: 400,
|
goal: 400,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 60 seconds.' });
|
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room lights strobing for 60 seconds.' });
|
||||||
},
|
},
|
||||||
async recover(ctx, effect) {
|
async recover(ctx, effect) {
|
||||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
|
|||||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
const { compareRoversForAssignment } = require('./roverRanking');
|
const { compareRoversForAssignment } = require('./roverRanking');
|
||||||
|
|
||||||
const socketRefs = new Map(); // socketId -> socket
|
const socketRefs = new Map(); // socketId -> socket
|
||||||
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function assignSocket(socket, options = {}) {
|
function assignSocket(socket, options = {}) {
|
||||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// avoid double assignment
|
// avoid double assignment
|
||||||
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
assignmentEvents,
|
assignmentEvents,
|
||||||
|
resumeAssignment: assignSocket,
|
||||||
|
releaseForOperatingMode: (socket) => {
|
||||||
|
// Include direct admin control as well as automatic assignment ownership.
|
||||||
|
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
|
||||||
|
const assigned = assignments.get(socket.id);
|
||||||
|
if (assigned) roverIds.add(assigned);
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
|
||||||
|
},
|
||||||
describeAssignment,
|
describeAssignment,
|
||||||
forceRelease,
|
forceRelease,
|
||||||
forceReleaseWithNotice,
|
forceReleaseWithNotice,
|
||||||
rerollAssignments,
|
rerollAssignments,
|
||||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||||
if (!socket || !roverId) return;
|
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
|
||||||
const previous = assignments.get(socket.id);
|
const previous = assignments.get(socket.id);
|
||||||
if (previous && previous !== roverId && releasePrevious) {
|
if (previous && previous !== roverId && releasePrevious) {
|
||||||
roverManager.releaseControl(previous, socket);
|
roverManager.releaseControl(previous, socket);
|
||||||
|
|||||||
@@ -2,20 +2,14 @@
|
|||||||
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
||||||
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const io = require('../../globals/io');
|
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
const { describeAssignment } = require('../assignmentService');
|
const { describeAssignment } = require('../assignmentService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const ptzCameraService = require('../ptzCameraService');
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
|
||||||
function resolvePtzChatTarget(socketId) {
|
|
||||||
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoverId(socketId) {
|
function resolveRoverId(socketId) {
|
||||||
const ptzTarget = resolvePtzChatTarget(socketId);
|
|
||||||
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
|
||||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
const assignment = describeAssignment(socketId);
|
const assignment = describeAssignment(socketId);
|
||||||
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
|
|||||||
|
|
||||||
function resolveRoverColor(roverId) {
|
function resolveRoverColor(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
||||||
return ptzCameraService.getPublicState()?.color || null;
|
|
||||||
}
|
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.color || null;
|
return record?.meta?.color || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRoverName(roverId) {
|
function resolveRoverName(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
||||||
return ptzCameraService.getPublicState()?.name || null;
|
|
||||||
}
|
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.name || null;
|
return record?.meta?.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPtzChatTargetId(roverId) {
|
|
||||||
/*
|
|
||||||
PTZ is intentionally treated as a virtual rover for chat identity only. It
|
|
||||||
does not live in roverManager.rovers because movement, video authorization,
|
|
||||||
and queue ownership are PTZ-service concerns, but chat needs one stable
|
|
||||||
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
|
|
||||||
code can all render the same badge without learning PTZ internals.
|
|
||||||
*/
|
|
||||||
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPublicChatTargetId(roverId, socket = null) {
|
function isPublicChatTargetId(roverId, socket = null) {
|
||||||
if (!roverId) return false;
|
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
|
||||||
/*
|
|
||||||
Normal rovers remain governed by the existing replay visibility rule, which
|
|
||||||
is also the rule chat historically used to avoid exposing closed private
|
|
||||||
rover activity. PTZ gets an explicit allow-list entry here because it is a
|
|
||||||
public chat target that deliberately pretends to be a rover, even though it
|
|
||||||
is not a roverManager record.
|
|
||||||
*/
|
|
||||||
if (isPtzChatTargetId(roverId)) return true;
|
|
||||||
return roverManager.canReplayRoverId(roverId, socket) === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateClosedRoverId(roverId) {
|
function isPrivateClosedRoverId(roverId) {
|
||||||
if (!roverId) return false;
|
return Boolean(roverId) && !isPublicChatTargetId(roverId);
|
||||||
/*
|
|
||||||
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
|
|
||||||
but it is not a private rover. Let PTZ-badged messages broadcast normally
|
|
||||||
instead of falling into the closed-private rover path for unknown ids.
|
|
||||||
*/
|
|
||||||
return !isPublicChatTargetId(roverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileImageUrl(value) {
|
function normalizeProfileImageUrl(value) {
|
||||||
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
|
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||||
|
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||||
roverId,
|
roverId,
|
||||||
roverName,
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
socketId,
|
socketId,
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
|
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||||
|
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||||
roverId,
|
roverId,
|
||||||
roverName,
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveRoverId,
|
resolveRoverId,
|
||||||
isPtzChatTargetId,
|
|
||||||
isPublicChatTargetId,
|
isPublicChatTargetId,
|
||||||
isPrivateClosedRoverId,
|
isPrivateClosedRoverId,
|
||||||
buildRoverCtxSnapshot,
|
buildRoverCtxSnapshot,
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
|||||||
|
|
||||||
function playTypingNote(roverId, note, socketId) {
|
function playTypingNote(roverId, note, socketId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
/*
|
|
||||||
PTZ borrows the roverId field for chat badges, but it has no rover command
|
|
||||||
channel. Skipping the song command here keeps PTZ chat from producing noisy
|
|
||||||
"unknown rover" command attempts while still allowing the message itself to
|
|
||||||
behave like rover chat everywhere else.
|
|
||||||
*/
|
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
|
||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'song',
|
type: 'song',
|
||||||
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeSpeak(socket, message, ttsOptions) {
|
function maybeSpeak(socket, message, ttsOptions) {
|
||||||
if (!ttsOptions || !message?.roverId) return;
|
if (!ttsOptions || !message) return;
|
||||||
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
if (message.operatingMode === 'ptz') {
|
||||||
/*
|
/*
|
||||||
PTZ has no rover websocket, but it does have a real speaker behind the
|
PTZ has no rover websocket, but it does have a real speaker behind the
|
||||||
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
||||||
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!message.roverId) return;
|
||||||
const record = roverManager.rovers.get(message.roverId);
|
const record = roverManager.rovers.get(message.roverId);
|
||||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const { getNickname } = require('../nicknameService');
|
|||||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
// Both command transports use the same activity lock owner.
|
||||||
|
const homeAssistantActivitiesService = require('../homeAssistantActivitiesService');
|
||||||
const greenModeService = require('../greenModeService');
|
const greenModeService = require('../greenModeService');
|
||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
@@ -177,6 +179,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
// lights lock/unlock` from becoming transport-specific, and it preserves
|
// lights lock/unlock` from becoming transport-specific, and it preserves
|
||||||
// the existing session update path for all connected browsers.
|
// the existing session update path for all connected browsers.
|
||||||
homeAssistantService,
|
homeAssistantService,
|
||||||
|
homeAssistantActivitiesService,
|
||||||
greenModeService,
|
greenModeService,
|
||||||
liftService,
|
liftService,
|
||||||
neatoService,
|
neatoService,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const logger = require('../../globals/logger').child('commandService');
|
|||||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||||
|
const { subscribeCommandRecording, observeCommand, recordCommandRequest } = require('./recording');
|
||||||
|
|
||||||
// Command observations are intentionally separate from the global event bus.
|
// Command observations are intentionally separate from the global event bus.
|
||||||
// Drive and motor commands can run at control-loop frequency, and publishing
|
// 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 normalizedPayload = normalizeOutboundCommandPayload(payload);
|
||||||
const message = { ...normalizedPayload, id };
|
const message = { ...normalizedPayload, id };
|
||||||
record.ws.send(JSON.stringify(message));
|
record.ws.send(JSON.stringify(message));
|
||||||
|
observeCommand({ phase: 'dispatched', roverId, commandId: id, command: normalizedPayload });
|
||||||
pendingCommands.set(id, { roverId, ts: Date.now(), type: normalizedPayload.type });
|
pendingCommands.set(id, { roverId, ts: Date.now(), type: normalizedPayload.type });
|
||||||
logger.info('Issued command', roverId, normalizedPayload.type, id);
|
logger.info('Issued command', roverId, normalizedPayload.type, id);
|
||||||
return id;
|
return id;
|
||||||
@@ -130,6 +132,7 @@ function handleAck(msg) {
|
|||||||
const pending = pendingCommands.get(msg.id);
|
const pending = pendingCommands.get(msg.id);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
pendingCommands.delete(msg.id);
|
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);
|
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
||||||
io.emit('commandAck', {
|
io.emit('commandAck', {
|
||||||
roverId: pending.roverId,
|
roverId: pending.roverId,
|
||||||
@@ -255,10 +258,12 @@ module.exports = {
|
|||||||
getRecentDriveActivity,
|
getRecentDriveActivity,
|
||||||
setDriveCooldown,
|
setDriveCooldown,
|
||||||
commandEvents,
|
commandEvents,
|
||||||
|
subscribeCommandRecording,
|
||||||
|
submitCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
// Shared authorization/safety boundary for browser and service-owned control.
|
||||||
function handleCommand({ roverId, type, data } = {}, cb) {
|
function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
||||||
const reply = typeof cb === 'function' ? cb : () => {};
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
try {
|
try {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
@@ -392,8 +397,10 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('command', handleCommand);
|
io.on('connection', (socket) => {
|
||||||
socket.on('command:issue', handleCommand);
|
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) => {
|
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
|
||||||
const reply = typeof cb === 'function' ? 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 };
|
||||||
@@ -6,12 +6,14 @@ const { MessageFlags } = require('discord.js');
|
|||||||
function createChannelIO({ client, logger, sanitizeMentions }) {
|
function createChannelIO({ client, logger, sanitizeMentions }) {
|
||||||
const channelCache = new Map();
|
const channelCache = new Map();
|
||||||
const typingMessageCache = new Map();
|
const typingMessageCache = new Map();
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
async function fetchChannel(id) {
|
async function fetchChannel(id) {
|
||||||
if (!id) return null;
|
if (stopped || !client.isReady() || !id) return null;
|
||||||
if (channelCache.has(id)) return channelCache.get(id);
|
if (channelCache.has(id)) return channelCache.get(id);
|
||||||
try {
|
try {
|
||||||
const channel = await client.channels.fetch(id);
|
const channel = await client.channels.fetch(id);
|
||||||
|
if (stopped) return null;
|
||||||
if (channel) {
|
if (channel) {
|
||||||
channelCache.set(id, channel);
|
channelCache.set(id, channel);
|
||||||
return channel;
|
return channel;
|
||||||
@@ -68,6 +70,7 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
|
|||||||
const content = `-# *${username} is typing...*`;
|
const content = `-# *${username} is typing...*`;
|
||||||
try {
|
try {
|
||||||
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
|
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
|
||||||
|
if (stopped) return;
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
clearTypingMessage(entry.guildId, typingId);
|
clearTypingMessage(entry.guildId, typingId);
|
||||||
}, 20000);
|
}, 20000);
|
||||||
@@ -78,6 +81,12 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
for (const record of typingMessageCache.values()) clearTimeout(record.timeoutId);
|
||||||
|
typingMessageCache.clear();
|
||||||
|
channelCache.clear();
|
||||||
|
},
|
||||||
fetchChannel,
|
fetchChannel,
|
||||||
sendToChannel,
|
sendToChannel,
|
||||||
clearTypingMessage,
|
clearTypingMessage,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ module.exports = {
|
|||||||
adminAlerts: '123456789012345678',
|
adminAlerts: '123456789012345678',
|
||||||
replay: '123456789012345678',
|
replay: '123456789012345678',
|
||||||
humanAlerts: '123456789012345678',
|
humanAlerts: '123456789012345678',
|
||||||
|
liveStatus: '',
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
stalkerPing: '123456789012345678',
|
stalkerPing: '123456789012345678',
|
||||||
@@ -31,6 +32,7 @@ module.exports = {
|
|||||||
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
|
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
|
||||||
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
|
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||||
channels: strictObject({
|
channels: strictObject({
|
||||||
|
liveStatus: string({ title: 'Live status', description: 'Dedicated live fleet report channel ID. The bot deletes ALL other messages here. Requires View Channel, Read Message History, Send Messages, and Manage Messages. Leave empty to disable.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||||
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
|
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||||
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
|
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||||
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
|
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Owns connection replacement and recovery from failed initial logins.
|
||||||
|
function createConnectionManager({ createRuntime, logger }) {
|
||||||
|
let desired = { enabled: false, token: '' };
|
||||||
|
let revision = 0;
|
||||||
|
let runtime = null;
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
let retryTimer = null;
|
||||||
|
let retryDelay = 5000;
|
||||||
|
|
||||||
|
function enqueue(operation) {
|
||||||
|
const pending = queue.then(operation);
|
||||||
|
// Report failures without poisoning subsequent configuration changes.
|
||||||
|
queue = pending.catch((err) => logger.error('Discord connection update failed', err.message));
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
const previous = runtime;
|
||||||
|
runtime = null;
|
||||||
|
if (!previous) return;
|
||||||
|
previous.stop();
|
||||||
|
// discord.js cannot cancel login while gateway discovery is pending.
|
||||||
|
// Let that attempt settle before destroying it, otherwise it can open a
|
||||||
|
// socket after destroy() has already run and leave an orphan connection.
|
||||||
|
await previous.login;
|
||||||
|
await previous.client.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(version) {
|
||||||
|
if (version !== revision || !desired.enabled) return;
|
||||||
|
const delay = retryDelay;
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 60000);
|
||||||
|
logger.warn('Retrying Discord login', { delayMs: delay });
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
enqueue(() => start(version));
|
||||||
|
}, delay);
|
||||||
|
retryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(version) {
|
||||||
|
if (version !== revision || !desired.enabled) return;
|
||||||
|
await stop();
|
||||||
|
if (version !== revision || !desired.enabled) return;
|
||||||
|
const current = createRuntime();
|
||||||
|
runtime = current;
|
||||||
|
const { client } = current;
|
||||||
|
client.on('error', (err) => logger.error('Discord client error', err.message));
|
||||||
|
client.on('shardError', (err, shardId) => {
|
||||||
|
logger.warn('Discord connection error', { shardId, error: err.message });
|
||||||
|
});
|
||||||
|
client.on('shardDisconnect', (event, shardId) => {
|
||||||
|
logger.warn('Discord disconnected', { shardId, code: event.code });
|
||||||
|
});
|
||||||
|
client.on('clientReady', () => { retryDelay = 5000; });
|
||||||
|
|
||||||
|
// Login must not block configuration saves while the network is down.
|
||||||
|
// Each attempt has its own client, so stale login completion/cleanup cannot
|
||||||
|
// clear the credentials or stop the connection of its replacement.
|
||||||
|
current.login = client.login(desired.token).catch((err) => {
|
||||||
|
if (version !== revision || runtime !== current) return;
|
||||||
|
logger.error('Discord login failed', err.message);
|
||||||
|
enqueue(async () => {
|
||||||
|
if (version !== revision || runtime !== current) return;
|
||||||
|
await stop();
|
||||||
|
// Credentials require an operator edit; retry network/service failures.
|
||||||
|
if (err.code === 'TokenInvalid' || err.status === 401) return;
|
||||||
|
scheduleRetry(version);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure({ enabled, token }) {
|
||||||
|
const next = { enabled: Boolean(enabled), token };
|
||||||
|
if (next.enabled === desired.enabled && next.token === desired.token) return queue;
|
||||||
|
desired = next;
|
||||||
|
const version = ++revision;
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
retryTimer = null;
|
||||||
|
retryDelay = 5000;
|
||||||
|
return enqueue(async () => {
|
||||||
|
if (version !== revision) return;
|
||||||
|
if (desired.enabled) await start(version);
|
||||||
|
else await stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configure,
|
||||||
|
refresh() { runtime?.refresh(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createConnectionManager };
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
const { createConnectionManager } = require('./connection');
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(t, attempts = []) {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const runtimes = [];
|
||||||
|
const logs = [];
|
||||||
|
const manager = createConnectionManager({
|
||||||
|
logger: {
|
||||||
|
warn: (...args) => logs.push(args),
|
||||||
|
error: (...args) => logs.push(args),
|
||||||
|
},
|
||||||
|
createRuntime() {
|
||||||
|
const attempt = attempts[runtimes.length] || {};
|
||||||
|
const client = new EventEmitter();
|
||||||
|
const runtime = {
|
||||||
|
client,
|
||||||
|
stopped: false,
|
||||||
|
destroyed: false,
|
||||||
|
refresh() {},
|
||||||
|
stop() { runtime.stopped = true; },
|
||||||
|
};
|
||||||
|
client.login = async (token) => {
|
||||||
|
runtime.token = token;
|
||||||
|
await attempt.login?.();
|
||||||
|
};
|
||||||
|
client.destroy = async () => {
|
||||||
|
await attempt.destroy?.();
|
||||||
|
runtime.destroyed = true;
|
||||||
|
};
|
||||||
|
runtimes.push(runtime);
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { manager, runtimes, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('retries a network failure at startup with a fresh client', async (t) => {
|
||||||
|
const { manager, runtimes } = setup(t, [{ login: () => { throw new Error('getaddrinfo EAI_AGAIN'); } }]);
|
||||||
|
await manager.configure({ enabled: true, token: 'token' });
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes[0].stopped, true);
|
||||||
|
assert.equal(runtimes[0].destroyed, true);
|
||||||
|
t.mock.timers.tick(4999);
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes.length, 1);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes.length, 2);
|
||||||
|
assert.equal(runtimes[1].token, 'token');
|
||||||
|
assert.notEqual(runtimes[0].client, runtimes[1].client);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backs off repeated failures and cancels retries when disabled', async (t) => {
|
||||||
|
const failed = { login: () => { throw new Error('Network unavailable'); } };
|
||||||
|
const { manager, runtimes, logs } = setup(t, Array(8).fill(failed));
|
||||||
|
await manager.configure({ enabled: true, token: 'token' });
|
||||||
|
await flush();
|
||||||
|
for (const delay of [5000, 10000, 20000, 40000, 60000]) {
|
||||||
|
t.mock.timers.tick(delay);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
assert.deepEqual(logs.filter(([message]) => message === 'Retrying Discord login').map(([, data]) => data.delayMs),
|
||||||
|
[5000, 10000, 20000, 40000, 60000, 60000]);
|
||||||
|
await manager.configure({ enabled: false, token: 'token' });
|
||||||
|
t.mock.timers.tick(120000);
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waits for logout before logging in with a changed token', async (t) => {
|
||||||
|
const logout = deferred();
|
||||||
|
const { manager, runtimes } = setup(t, [{ destroy: () => logout.promise }]);
|
||||||
|
await manager.configure({ enabled: true, token: 'old' });
|
||||||
|
const change = manager.configure({ enabled: true, token: 'new' });
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes[0].stopped, true);
|
||||||
|
assert.equal(runtimes.length, 1);
|
||||||
|
logout.resolve();
|
||||||
|
await change;
|
||||||
|
assert.equal(runtimes[0].destroyed, true);
|
||||||
|
assert.equal(runtimes[1].token, 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retires a pending login before replacement and ignores its stale failure', async (t) => {
|
||||||
|
const login = deferred();
|
||||||
|
const { manager, runtimes } = setup(t, [{ login: () => login.promise }]);
|
||||||
|
await manager.configure({ enabled: true, token: 'old' });
|
||||||
|
const change = manager.configure({ enabled: true, token: 'new' });
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes[0].stopped, true);
|
||||||
|
assert.equal(runtimes[0].destroyed, false);
|
||||||
|
login.reject(new Error('Old connection failed'));
|
||||||
|
await change;
|
||||||
|
assert.equal(runtimes[0].destroyed, true);
|
||||||
|
assert.equal(runtimes[1].token, 'new');
|
||||||
|
t.mock.timers.tick(120000);
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-enabling creates a fresh client; unrelated config edits do not reconnect', async (t) => {
|
||||||
|
const { manager, runtimes } = setup(t);
|
||||||
|
await manager.configure({ enabled: true, token: 'token' });
|
||||||
|
await manager.configure({ enabled: true, token: 'token', channels: { general: 'new-channel' } });
|
||||||
|
assert.equal(runtimes.length, 1);
|
||||||
|
await manager.configure({ enabled: false, token: 'token' });
|
||||||
|
assert.equal(runtimes[0].destroyed, true);
|
||||||
|
await manager.configure({ enabled: true, token: 'token' });
|
||||||
|
assert.equal(runtimes.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid credentials wait for a token edit instead of retrying', async (t) => {
|
||||||
|
const { manager, runtimes } = setup(t, [{ login: () => {
|
||||||
|
throw Object.assign(new Error('Invalid token'), { code: 'TokenInvalid' });
|
||||||
|
} }]);
|
||||||
|
await manager.configure({ enabled: true, token: 'bad' });
|
||||||
|
await flush();
|
||||||
|
t.mock.timers.tick(120000);
|
||||||
|
await flush();
|
||||||
|
assert.equal(runtimes.length, 1);
|
||||||
|
await manager.configure({ enabled: true, token: 'fixed' });
|
||||||
|
assert.equal(runtimes[1].token, 'fixed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client and gateway errors are logged without throwing', async (t) => {
|
||||||
|
const { manager, runtimes, logs } = setup(t);
|
||||||
|
await manager.configure({ enabled: true, token: 'token' });
|
||||||
|
runtimes[0].client.emit('error', new Error('Client failure'));
|
||||||
|
runtimes[0].client.emit('shardError', new Error('Gateway failure'), 0);
|
||||||
|
runtimes[0].client.emit('shardDisconnect', { code: 1006 }, 0);
|
||||||
|
assert.deepEqual(logs.map(([message]) => message), [
|
||||||
|
'Discord client error', 'Discord connection error', 'Discord disconnected',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -22,11 +22,13 @@ const { MODES, getMode, setMode } = require('../modeManager');
|
|||||||
const { sendExternalMessage, sendExternalTyping } = require('../chatService');
|
const { sendExternalMessage, sendExternalTyping } = require('../chatService');
|
||||||
const { commandReplyToText } = require('../chatService/commandResultFormatter');
|
const { commandReplyToText } = require('../chatService/commandResultFormatter');
|
||||||
const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2');
|
const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2');
|
||||||
const { getActiveDrivers } = require('../turnService');
|
const { getActiveDrivers, turnEvents } = require('../turnService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
// Both command transports use the same activity lock owner.
|
||||||
|
const homeAssistantActivitiesService = require('../homeAssistantActivitiesService');
|
||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
const {
|
const {
|
||||||
@@ -65,12 +67,14 @@ const {
|
|||||||
} = require('../privateRoverAccessRequestService');
|
} = require('../privateRoverAccessRequestService');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
const { createPresenceManager } = require('./presence');
|
const { createPresenceManager } = require('./presence');
|
||||||
|
const { createConnectionManager } = require('./connection');
|
||||||
const { createChannelIO } = require('./channelIO');
|
const { createChannelIO } = require('./channelIO');
|
||||||
const { createCommandHandlers } = require('../operatorCommandService');
|
const { createCommandHandlers } = require('../operatorCommandService');
|
||||||
const greenModeService = require('../greenModeService');
|
const greenModeService = require('../greenModeService');
|
||||||
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
|
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
|
||||||
const { createIntegrations } = require('./integrations');
|
const { createIntegrations } = require('./integrations');
|
||||||
const { createFleetDailyReports } = require('./fleetDailyReports');
|
const { createFleetDailyReports } = require('./fleetDailyReports');
|
||||||
|
const { createLiveStatus } = require('./liveStatus');
|
||||||
const fleetReportService = require('../fleetReportService');
|
const fleetReportService = require('../fleetReportService');
|
||||||
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
|
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
|
||||||
const {
|
const {
|
||||||
@@ -84,9 +88,7 @@ const {
|
|||||||
buildStatusMessage,
|
buildStatusMessage,
|
||||||
} = require('../replayDeliveryService/workflow');
|
} = require('../replayDeliveryService/workflow');
|
||||||
|
|
||||||
// Discord helper modules retain references to these objects. Mutating those
|
// Keep configuration object identities stable for the active runtime's helpers.
|
||||||
// references on configuration application updates command and integration
|
|
||||||
// behavior without registering a second tree of Discord/event listeners.
|
|
||||||
const config = structuredClone(loadConfig());
|
const config = structuredClone(loadConfig());
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
let enabled = Boolean(discordConfig.enabled);
|
let enabled = Boolean(discordConfig.enabled);
|
||||||
@@ -104,11 +106,6 @@ const intents = [
|
|||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents,
|
|
||||||
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
|
||||||
});
|
|
||||||
|
|
||||||
function sanitizeMentions(text) {
|
function sanitizeMentions(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return String(text)
|
return String(text)
|
||||||
@@ -154,6 +151,13 @@ function countReady() {
|
|||||||
return { ready, total };
|
return { ready, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each connection owns its listeners, timers, and cached Discord objects.
|
||||||
|
// Rebuilding them prevents a reconnect from retaining a destroyed client.
|
||||||
|
function createDiscordRuntime() {
|
||||||
|
const client = new Client({
|
||||||
|
intents,
|
||||||
|
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
||||||
|
});
|
||||||
const channelIO = createChannelIO({
|
const channelIO = createChannelIO({
|
||||||
client,
|
client,
|
||||||
logger,
|
logger,
|
||||||
@@ -179,7 +183,7 @@ const replayCaption = createReplayCaptionBuilder({
|
|||||||
// Discord is the preferred replay host only while this optional feature is
|
// Discord is the preferred replay host only while this optional feature is
|
||||||
// active. The core replay delivery service owns generation and automatically
|
// active. The core replay delivery service owns generation and automatically
|
||||||
// falls back to its local media store when any operation below fails.
|
// falls back to its local media store when any operation below fails.
|
||||||
registerPreferredDeliveryProvider({
|
const unregisterReplayProvider = registerPreferredDeliveryProvider({
|
||||||
async begin(job) {
|
async begin(job) {
|
||||||
const channelId = discordConfig.channels?.replay;
|
const channelId = discordConfig.channels?.replay;
|
||||||
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
|
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
|
||||||
@@ -262,6 +266,7 @@ const commandDependencies = {
|
|||||||
// service into the shared command router keeps Discord and mirrored web-chat
|
// service into the shared command router keeps Discord and mirrored web-chat
|
||||||
// command behavior aligned without duplicating Home Assistant calls here.
|
// command behavior aligned without duplicating Home Assistant calls here.
|
||||||
homeAssistantService,
|
homeAssistantService,
|
||||||
|
homeAssistantActivitiesService,
|
||||||
greenModeService,
|
greenModeService,
|
||||||
liftService,
|
liftService,
|
||||||
neatoService,
|
neatoService,
|
||||||
@@ -331,6 +336,11 @@ const commands = createCommandHandlers(commandDependencies);
|
|||||||
});
|
});
|
||||||
|
|
||||||
const integrationHandlers = integrations.register();
|
const integrationHandlers = integrations.register();
|
||||||
|
const liveStatus = createLiveStatus({
|
||||||
|
client, logger, discordConfig, roverManager, getActiveDrivers, getNickname,
|
||||||
|
io, fetchChannel: channelIO.fetchChannel, sanitizeMentions,
|
||||||
|
turnEvents, nicknameEvents,
|
||||||
|
});
|
||||||
|
|
||||||
function isTextCommand(content) {
|
function isTextCommand(content) {
|
||||||
// Both transports share this parser so command detection cannot drift from
|
// Both transports share this parser so command detection cannot drift from
|
||||||
@@ -379,6 +389,10 @@ function createBridgeMirroredCommandMessage(message) {
|
|||||||
|
|
||||||
client.on('messageCreate', async (message) => {
|
client.on('messageCreate', async (message) => {
|
||||||
try {
|
try {
|
||||||
|
if (message.channelId === discordConfig.channels?.liveStatus?.trim()) {
|
||||||
|
liveStatus.update(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await integrationHandlers.handleBridgeInbound(message);
|
await integrationHandlers.handleBridgeInbound(message);
|
||||||
const commandMessage = createBridgeMirroredCommandMessage(message);
|
const commandMessage = createBridgeMirroredCommandMessage(message);
|
||||||
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
|
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
|
||||||
@@ -402,51 +416,57 @@ function restartFleetDailyReports() {
|
|||||||
fleetDailyReports.start();
|
fleetDailyReports.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
client.on('ready', () => {
|
client.on('clientReady', () => {
|
||||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||||
presence.schedulePresenceRotation();
|
presence.schedulePresenceRotation();
|
||||||
// Discord is only a delivery consumer. Starting its scheduler after the bot
|
// Discord is only a delivery consumer. Starting its scheduler after the bot
|
||||||
// is ready avoids failed sends during login while the collector continues to
|
// is ready avoids failed sends during login while the collector continues to
|
||||||
// operate independently of Discord availability.
|
// operate independently of Discord availability.
|
||||||
restartFleetDailyReports();
|
restartFleetDailyReports();
|
||||||
|
liveStatus.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
refresh() {
|
||||||
|
if (client.isReady()) restartFleetDailyReports();
|
||||||
|
if (client.isReady()) liveStatus.start();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
for (const event of ['clientReady', 'messageCreate', 'typingStart', 'messageReactionAdd']) {
|
||||||
|
client.removeAllListeners(event);
|
||||||
|
}
|
||||||
|
integrationHandlers.stop();
|
||||||
|
unregisterReplayProvider();
|
||||||
|
presence.stop();
|
||||||
|
liveStatus.stop();
|
||||||
|
channelIO.stop();
|
||||||
|
fleetDailyReports?.stop();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = createConnectionManager({ createRuntime: createDiscordRuntime, logger });
|
||||||
|
|
||||||
function replaceObject(target, source = {}) {
|
function replaceObject(target, source = {}) {
|
||||||
Object.keys(target).forEach((key) => delete target[key]);
|
Object.keys(target).forEach((key) => delete target[key]);
|
||||||
Object.assign(target, structuredClone(source));
|
Object.assign(target, structuredClone(source));
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDiscordConfig(nextDiscordConfig = {}) {
|
async function applyDiscordConfig(nextDiscordConfig = {}) {
|
||||||
const wasEnabled = enabled;
|
|
||||||
const previousToken = discordConfig.token;
|
|
||||||
replaceObject(discordConfig, nextDiscordConfig);
|
replaceObject(discordConfig, nextDiscordConfig);
|
||||||
config.discord = discordConfig;
|
config.discord = discordConfig;
|
||||||
enabled = Boolean(discordConfig.enabled);
|
enabled = Boolean(discordConfig.enabled);
|
||||||
|
await connection.configure(discordConfig);
|
||||||
if (!enabled) {
|
connection.refresh();
|
||||||
fleetDailyReports?.stop();
|
|
||||||
fleetDailyReports = null;
|
|
||||||
if (wasEnabled) client.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!wasEnabled || previousToken !== discordConfig.token) {
|
|
||||||
if (wasEnabled) client.destroy();
|
|
||||||
// Login health is reported by Discord itself; do not hold the committed
|
|
||||||
// configuration request open while an external network service connects.
|
|
||||||
client.login(discordConfig.token).catch((err) => {
|
|
||||||
logger.error('Discord login failed after configuration change', err.message);
|
|
||||||
});
|
|
||||||
} else if (client.isReady()) {
|
|
||||||
restartFleetDailyReports();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySharedConfigSection(section, value) {
|
function applySharedConfigSection(section, value) {
|
||||||
config[section] = structuredClone(value);
|
config[section] = structuredClone(value);
|
||||||
// Fleet delivery owns a timer derived from both Discord and fleet settings.
|
// Fleet delivery owns a timer derived from both Discord and fleet settings.
|
||||||
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
|
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
|
||||||
if (section === 'fleetReports' && client.isReady()) {
|
if (section === 'fleetReports') {
|
||||||
restartFleetDailyReports();
|
connection.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,10 +475,8 @@ registerConfigurationHandler('discord', applyDiscordConfig);
|
|||||||
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
|
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enabled) {
|
connection.configure(discordConfig).catch((err) => {
|
||||||
client.login(discordConfig.token).catch((err) => {
|
logger.error('Discord startup failed', err.message);
|
||||||
logger.error('Discord login failed', err.message);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {};
|
module.exports = {};
|
||||||
|
|||||||
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
|
|||||||
function handleChatBridgeOutbound(event) {
|
function handleChatBridgeOutbound(event) {
|
||||||
const payload = event?.payload;
|
const payload = event?.payload;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
/*
|
|
||||||
Outbound bridge filtering must use chat visibility, not rover replay
|
|
||||||
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
|
|
||||||
chat badge path can be reused, but that id is not a roverManager rover and
|
|
||||||
would be dropped by canReplayRoverId().
|
|
||||||
*/
|
|
||||||
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
||||||
const guildConfigs = listGuildConfigs();
|
const guildConfigs = listGuildConfigs();
|
||||||
if (!guildConfigs.length) return;
|
if (!guildConfigs.length) return;
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
|
|||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||||
}
|
}
|
||||||
/*
|
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
|
||||||
The chat payload already carries the resolved display name for rover-like
|
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
|
||||||
targets. Prefer that name so PTZ, which is intentionally pretending to be a
|
|
||||||
rover in chat, shows up as "PTZ Camera" instead of the internal id
|
|
||||||
"ptz-camera"; fall back to the id for older payloads or missing metadata.
|
|
||||||
*/
|
|
||||||
const roverTagLabel = payload.roverName || payload.roverId;
|
|
||||||
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
|
|
||||||
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createIntegrations(deps) {
|
|||||||
const userAnnouncements = createUserAnnouncements({ ...deps, sendToChannel, schedulePresenceRotation });
|
const userAnnouncements = createUserAnnouncements({ ...deps, sendToChannel, schedulePresenceRotation });
|
||||||
|
|
||||||
function register() {
|
function register() {
|
||||||
|
let stopped = false;
|
||||||
client.on('typingStart', (typing) => {
|
client.on('typingStart', (typing) => {
|
||||||
chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message));
|
chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message));
|
||||||
});
|
});
|
||||||
@@ -33,14 +34,31 @@ function createIntegrations(deps) {
|
|||||||
dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message));
|
dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribe('*', handleBusEvent);
|
// Only the active, ready connection consumes server events. Retain each
|
||||||
subscribe('*', userAnnouncements.handleBusEvent);
|
// unsubscribe function so replacement clients never duplicate deliveries.
|
||||||
subscribe('verification.requested', dm.sendVerificationRequestDms);
|
const unsubscribe = [];
|
||||||
subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
|
function subscribeWhileReady(type, handler) {
|
||||||
subscribe('chat:message', chat.handleChatBridgeOutbound);
|
unsubscribe.push(subscribe(type, (event) => {
|
||||||
subscribe('chat:typing', chat.handleChatTypingOutbound);
|
Promise.resolve().then(() => {
|
||||||
|
if (!stopped && client.isReady()) return handler(event);
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.warn('Error handling Discord integration event', { type, error: err.message });
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeWhileReady('*', handleBusEvent);
|
||||||
|
subscribeWhileReady('*', userAnnouncements.handleBusEvent);
|
||||||
|
subscribeWhileReady('verification.requested', dm.sendVerificationRequestDms);
|
||||||
|
subscribeWhileReady('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
|
||||||
|
subscribeWhileReady('chat:message', chat.handleChatBridgeOutbound);
|
||||||
|
subscribeWhileReady('chat:typing', chat.handleChatTypingOutbound);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
unsubscribe.forEach((remove) => remove());
|
||||||
|
},
|
||||||
handleBridgeInbound: chat.handleBridgeInbound,
|
handleBridgeInbound: chat.handleBridgeInbound,
|
||||||
handleBusEvent,
|
handleBusEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Owns the single-message live report and the dedicated channel's cleanup.
|
||||||
|
const { PermissionFlagsBits } = require('discord.js');
|
||||||
|
const { buildRoverStatusSnapshot } = require('./batteryEmbeds');
|
||||||
|
|
||||||
|
function createLiveStatus({ client, logger, discordConfig, roverManager, getActiveDrivers,
|
||||||
|
getNickname, io, fetchChannel, sanitizeMentions,
|
||||||
|
turnEvents, nicknameEvents }) {
|
||||||
|
const COOLDOWN_MS = 2000;
|
||||||
|
let timer = null;
|
||||||
|
let sweepTimer = null;
|
||||||
|
let pending = false;
|
||||||
|
let cleanupNeeded = false;
|
||||||
|
let nextUpdateAt = 0;
|
||||||
|
let listening = false;
|
||||||
|
const unsubscribe = [];
|
||||||
|
let stopped = false;
|
||||||
|
let running = false;
|
||||||
|
let channelId = null;
|
||||||
|
let messageId = null;
|
||||||
|
let lastContent = null;
|
||||||
|
|
||||||
|
const clean = (value, limit = 120) => sanitizeMentions(String(value ?? ''))
|
||||||
|
.replace(/[\r\n]+/g, ' ').slice(0, limit);
|
||||||
|
|
||||||
|
function buildReport() {
|
||||||
|
const drivers = getActiveDrivers();
|
||||||
|
const roster = roverManager.getRoster();
|
||||||
|
const lines = [];
|
||||||
|
for (const rover of roster) {
|
||||||
|
const snapshot = buildRoverStatusSnapshot(roverManager.rovers.get(rover.id));
|
||||||
|
const driverId = drivers[rover.id];
|
||||||
|
const driver = driverId ? clean(getNickname(io.sockets.sockets.get(driverId)) || 'Someone', 32) : null;
|
||||||
|
const status = snapshot?.docked ? 'docked' : rover.needsHelp ? 'needs help'
|
||||||
|
: driver ? `${driver} driving` : rover.locked ? 'locked' : 'available';
|
||||||
|
lines.push(`${clean(rover.name, 60)}: ${status}`);
|
||||||
|
}
|
||||||
|
let content = lines.join('\n') || 'No rovers online.';
|
||||||
|
if (content.length > 2000) content = `${content.slice(0, 1950)}\n… More rovers online.`;
|
||||||
|
return { content, allowedMentions: { parse: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (stopped || running || timer || !pending || !client.isReady()
|
||||||
|
|| !discordConfig.channels?.liveStatus?.trim()) return;
|
||||||
|
// Use a fixed deadline rather than resetting a debounce on every sensor
|
||||||
|
// frame: a continuously changing rover must not postpone updates forever.
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
flush();
|
||||||
|
}, Math.max(0, nextUpdateAt - Date.now()));
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(forceCleanup = false) {
|
||||||
|
if (stopped) return;
|
||||||
|
pending = true;
|
||||||
|
cleanupNeeded ||= forceCleanup;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
if (stopped || running || !client.isReady()) return;
|
||||||
|
pending = false;
|
||||||
|
const target = discordConfig.channels?.liveStatus?.trim();
|
||||||
|
if (target !== channelId) {
|
||||||
|
channelId = target;
|
||||||
|
messageId = null;
|
||||||
|
lastContent = null;
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
running = true;
|
||||||
|
const cleanup = cleanupNeeded;
|
||||||
|
cleanupNeeded = false;
|
||||||
|
let usedDiscord = false;
|
||||||
|
const active = () => !stopped && client.isReady()
|
||||||
|
&& discordConfig.channels?.liveStatus?.trim() === target;
|
||||||
|
try {
|
||||||
|
const report = buildReport();
|
||||||
|
const replace = report.content !== lastContent;
|
||||||
|
// Sensor events can arrive many times per second. Compare the rendered
|
||||||
|
// report locally before consuming any Discord API capacity.
|
||||||
|
if (!replace && !cleanup) return;
|
||||||
|
usedDiscord = true;
|
||||||
|
const channel = await fetchChannel(target);
|
||||||
|
if (!active()) return;
|
||||||
|
if (!channel?.isTextBased() || !channel.guild || !channel.messages || !channel.send) {
|
||||||
|
throw new Error('Live status requires a guild text channel');
|
||||||
|
}
|
||||||
|
const permissions = channel.permissionsFor(client.user);
|
||||||
|
if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory,
|
||||||
|
PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages])) {
|
||||||
|
throw new Error('Live status requires View Channel, Read Message History, Send Messages, and Manage Messages');
|
||||||
|
}
|
||||||
|
let found = false;
|
||||||
|
let before;
|
||||||
|
// Paginate the entire history, including pinned and old messages. Individual
|
||||||
|
// deletion also handles messages too old for Discord's bulk-delete endpoint.
|
||||||
|
while (active()) {
|
||||||
|
const messages = await channel.messages.fetch({ limit: 100, before, cache: false });
|
||||||
|
if (!active()) return;
|
||||||
|
for (const message of messages.values()) {
|
||||||
|
if (!active()) return;
|
||||||
|
if (!replace && message.id === messageId) {
|
||||||
|
found = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await message.delete();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 10008) throw err; // Already-deleted messages need no cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messages.size < 100) break;
|
||||||
|
before = messages.last().id;
|
||||||
|
}
|
||||||
|
if (!active()) return;
|
||||||
|
if (replace || !found) {
|
||||||
|
const message = await channel.send(report);
|
||||||
|
if (!active()) return;
|
||||||
|
messageId = message.id;
|
||||||
|
lastContent = report.content;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
cleanupNeeded = true;
|
||||||
|
logger.warn('Discord live status update failed', { channelId: target, error: err.message });
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
// Cool down after completion, so slow deletions and rate-limited requests
|
||||||
|
// cannot overlap with another batch. Events received meanwhile stay pending.
|
||||||
|
if (usedDiscord) nextUpdateAt = Date.now() + COOLDOWN_MS;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = () => update();
|
||||||
|
function onDelete(message) {
|
||||||
|
if (message.channelId === discordConfig.channels?.liveStatus?.trim()
|
||||||
|
&& message.id === messageId) update(true);
|
||||||
|
}
|
||||||
|
function onBulkDelete(messages) {
|
||||||
|
for (const message of messages.values()) onDelete(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
start() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (!listening) {
|
||||||
|
listening = true;
|
||||||
|
for (const event of ['rover', 'sensor', 'lock', 'private', 'help']) {
|
||||||
|
roverManager.managerEvents.on(event, onChange);
|
||||||
|
unsubscribe.push(() => roverManager.managerEvents.off(event, onChange));
|
||||||
|
}
|
||||||
|
turnEvents.on('activeDriver', onChange);
|
||||||
|
nicknameEvents.on('change', onChange);
|
||||||
|
client.on('messageDelete', onDelete);
|
||||||
|
client.on('messageDeleteBulk', onBulkDelete);
|
||||||
|
unsubscribe.push(
|
||||||
|
() => turnEvents.off('activeDriver', onChange),
|
||||||
|
() => nicknameEvents.off('change', onChange),
|
||||||
|
() => client.off('messageDelete', onDelete),
|
||||||
|
() => client.off('messageDeleteBulk', onBulkDelete),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
clearInterval(sweepTimer);
|
||||||
|
sweepTimer = null;
|
||||||
|
if (discordConfig.channels?.liveStatus?.trim()) {
|
||||||
|
// Recover from missed gateway events and failed operations without polling
|
||||||
|
// for normal rover changes, which are handled by the subscriptions above.
|
||||||
|
sweepTimer = setInterval(() => update(true), 30000);
|
||||||
|
sweepTimer.unref?.();
|
||||||
|
}
|
||||||
|
update(true);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearInterval(sweepTimer);
|
||||||
|
unsubscribe.forEach((remove) => remove());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLiveStatus };
|
||||||
@@ -7,6 +7,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
|
|||||||
const PRESENCE_ROTATE_MS = 20000;
|
const PRESENCE_ROTATE_MS = 20000;
|
||||||
let presenceInterval = null;
|
let presenceInterval = null;
|
||||||
let presenceShowObjective = false;
|
let presenceShowObjective = false;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
function truncatePresenceText(text, maxLength) {
|
function truncatePresenceText(text, maxLength) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -40,6 +41,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
|
|||||||
}
|
}
|
||||||
|
|
||||||
function schedulePresenceRotation() {
|
function schedulePresenceRotation() {
|
||||||
|
if (stopped) return;
|
||||||
if (presenceInterval) {
|
if (presenceInterval) {
|
||||||
clearInterval(presenceInterval);
|
clearInterval(presenceInterval);
|
||||||
presenceInterval = null;
|
presenceInterval = null;
|
||||||
@@ -59,6 +61,11 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearInterval(presenceInterval);
|
||||||
|
presenceInterval = null;
|
||||||
|
},
|
||||||
schedulePresenceRotation,
|
schedulePresenceRotation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async function setEnabled(nextValue, options = {}) {
|
|||||||
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
|
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
|
||||||
|
|
||||||
if (failures.length) {
|
if (failures.length) {
|
||||||
logger.warn('Some room controls failed to enter green mode', { failures });
|
logger.warn('Some room lights failed to enter green mode', { failures });
|
||||||
}
|
}
|
||||||
} else if (!next && homeAssistantService.enabled) {
|
} else if (!next && homeAssistantService.enabled) {
|
||||||
// Disabling the visual mode simply releases the lock it created. Bulb
|
// Disabling the visual mode simply releases the lock it created. Bulb
|
||||||
@@ -67,7 +67,7 @@ async function setEnabled(nextValue, options = {}) {
|
|||||||
skipping the physical-room operations still allows the session theme,
|
skipping the physical-room operations still allows the session theme,
|
||||||
CardFrame styling, alerts, commands, and timed reward to work normally.
|
CardFrame styling, alerts, commands, and timed reward to work normally.
|
||||||
The integration's generic lock state is also left untouched because there
|
The integration's generic lock state is also left untouched because there
|
||||||
are no server-managed room controls to lock.
|
are no server-managed room lights to lock.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
enabled = next;
|
enabled = next;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// User and idle commands share one dispatch contract, while only the trusted
|
||||||
|
// idle entry point can bypass per-item user locks.
|
||||||
|
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||||
|
|
||||||
|
function assertAccess(actor = {}) {
|
||||||
|
if (!['user', 'admin', 'lockdown'].includes(actor.role)) throw new Error('Spectators cannot control activities');
|
||||||
|
if (actor.mode === 'lockdown' && actor.role !== 'lockdown') throw new Error('Server in lockdown');
|
||||||
|
if (actor.mode === 'admin' && !['admin', 'lockdown'].includes(actor.role)) throw new Error('Admin mode: admins only');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActions({ getConfig, ha, locks }) {
|
||||||
|
async function execute(id, value, actor, idle = false) {
|
||||||
|
if (!idle) assertAccess(actor);
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config.enabled) throw new Error('Activity Controls are disabled');
|
||||||
|
const item = config.items.find((entry) => entry.id === id);
|
||||||
|
if (!item) throw new Error('Unknown activity item');
|
||||||
|
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
|
||||||
|
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
|
||||||
|
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
||||||
|
if (!entity.available) throw new Error('This item is unavailable');
|
||||||
|
const command = buildCommand(entity, value);
|
||||||
|
// Let HA process independent commands normally; an outstanding service
|
||||||
|
// response must not block later user changes or configured idle cleanup.
|
||||||
|
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runIdleActions() {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
|
||||||
|
const results = [];
|
||||||
|
// Catch per item so one offline integration cannot prevent the remaining
|
||||||
|
// configured cleanup actions from running during this idle window.
|
||||||
|
for (const item of config.items) {
|
||||||
|
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
|
||||||
|
try {
|
||||||
|
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
||||||
|
if (entity.type === 'readOnly') continue;
|
||||||
|
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
|
||||||
|
// The admin form omits an empty optional string. Treat that as empty
|
||||||
|
// text so clearing a message works; other types still reject it through
|
||||||
|
// their normal value validation rather than silently receiving zero.
|
||||||
|
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
|
||||||
|
results.push({ id: item.id, ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ id: item.id, ok: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { action: 'homeAssistantActivitiesIdle', results };
|
||||||
|
}
|
||||||
|
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createActions, assertAccess };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Exercise contracts without starting the app or connecting to Home Assistant.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||||
|
const { createActions } = require('./actions');
|
||||||
|
const { createLocks, resolveItem } = require('./locks');
|
||||||
|
const { createHaCommand } = require('../operatorCommandService/commands/ha');
|
||||||
|
|
||||||
|
const user = { role: 'user', mode: 'open' };
|
||||||
|
const raw = (state, attributes = {}) => ({ state, attributes });
|
||||||
|
function harness(items, snapshot) {
|
||||||
|
const calls = [];
|
||||||
|
const config = { enabled: true, items };
|
||||||
|
const locked = new Set();
|
||||||
|
const ha = {
|
||||||
|
enabled: true, isConnected: () => true,
|
||||||
|
getRawEntitySnapshot: (id) => snapshot[id],
|
||||||
|
callHomeAssistantService: async (...args) => { calls.push(args); },
|
||||||
|
};
|
||||||
|
return { config, ha, calls, locked, actions: createActions({ getConfig: () => config, ha, locks: { isLocked: (id) => locked.has(id) } }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sensor states, live constraints, and unknown button states are preserved', () => {
|
||||||
|
const sensor = buildEntity({ id: 'sensor.humidity' }, raw('46.3', { unit_of_measurement: '%', friendly_name: 'Humidity' }));
|
||||||
|
assert.equal(sensor.state, '46.3');
|
||||||
|
assert.equal(sensor.unit, '%');
|
||||||
|
assert.equal(sensor.type, 'readOnly');
|
||||||
|
assert.equal(sensor.name, 'Humidity');
|
||||||
|
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
|
||||||
|
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
||||||
|
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
|
||||||
|
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
|
||||||
|
const cases = [
|
||||||
|
['light.lamp', 'on', {}, 'turn_on', {}],
|
||||||
|
['switch.fan', 'off', {}, 'turn_off', {}],
|
||||||
|
['input_boolean.enabled', 'on', {}, 'turn_on', {}],
|
||||||
|
['number.speed', 0.3, { min: 0, max: 1, step: 0.1 }, 'set_value', { value: 0.3 }],
|
||||||
|
['input_number.speed', '2', { min: 0, max: 5, step: 1 }, 'set_value', { value: 2 }],
|
||||||
|
['text.message', 'hello', { min: 0, max: 20 }, 'set_value', { value: 'hello' }],
|
||||||
|
['input_text.message', '', { min: 0, max: 20 }, 'set_value', { value: '' }],
|
||||||
|
['select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
|
||||||
|
['input_select.mode', 'Quiet', { options: ['Quiet'] }, 'select_option', { option: 'Quiet' }],
|
||||||
|
['button.bell', 'press', {}, 'press', {}],
|
||||||
|
['input_button.bell', 'press', {}, 'press', {}],
|
||||||
|
];
|
||||||
|
// Each domain is exercised through the allowlist and action service rather
|
||||||
|
// than merely asserting that an internal mapping table contains an entry.
|
||||||
|
for (const [id, value, attributes, service, data] of cases) {
|
||||||
|
const h = harness([{ id }], { [id]: raw('unknown', attributes) });
|
||||||
|
if (!id.includes('button')) h.ha.getRawEntitySnapshot = () => raw('off', attributes);
|
||||||
|
await h.actions.act(id, value, user);
|
||||||
|
assert.deepEqual(h.calls, [[id.split('.')[0], service, { entity_id: id, ...data }]]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid values fail before an outbound request', () => {
|
||||||
|
const number = buildEntity({ id: 'number.speed' }, raw('0', { min: 0, max: 1, step: 0.1 }));
|
||||||
|
for (const value of ['', ' ', null, false, {}, Infinity, 'NaN', 2, 0.15]) assert.throws(() => buildCommand(number, value));
|
||||||
|
const text = buildEntity({ id: 'text.code' }, raw('ab', { min: 2, max: 4, pattern: '[a-z]+' }));
|
||||||
|
for (const value of ['', 'abcde', 123]) assert.throws(() => buildCommand(text, value));
|
||||||
|
// Pattern interpretation belongs to HA, even when metadata supplies one.
|
||||||
|
assert.equal(buildCommand(text, '12').data.value, '12');
|
||||||
|
assert.throws(() => buildCommand(buildEntity({ id: 'select.mode' }, raw('Quiet', { options: ['Quiet'] })), 'Other'));
|
||||||
|
assert.throws(() => buildCommand(buildEntity({ id: 'sensor.state' }, raw('ok')), 'on'), /read-only/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permissions, locks, availability and allowlist are authoritative', async () => {
|
||||||
|
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('on') });
|
||||||
|
await assert.rejects(h.actions.act('switch.room_only', 'off', user), /Unknown/);
|
||||||
|
for (const actor of [{ role: 'spectator', mode: 'open' }, { role: 'user', mode: 'admin' }, { role: 'admin', mode: 'lockdown' }]) {
|
||||||
|
await assert.rejects(h.actions.act('switch.fan', 'off', actor));
|
||||||
|
}
|
||||||
|
h.locked.add('switch.fan');
|
||||||
|
await assert.rejects(h.actions.act('switch.fan', 'off', user), /locked/);
|
||||||
|
assert.equal(h.calls.length, 0);
|
||||||
|
await h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' });
|
||||||
|
await h.actions.act('switch.fan', 'off', { role: 'lockdown', mode: 'lockdown' });
|
||||||
|
h.ha.isConnected = () => false;
|
||||||
|
await assert.rejects(h.actions.act('switch.fan', 'off', { role: 'admin', mode: 'open' }), /offline/);
|
||||||
|
h.config.enabled = false;
|
||||||
|
await assert.rejects(h.actions.act('switch.fan', 'off', user), /disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
||||||
|
const h = harness([
|
||||||
|
{ id: 'switch.keep' },
|
||||||
|
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
|
||||||
|
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
|
||||||
|
// An empty optional text box is omitted by the schema-driven admin form.
|
||||||
|
{ id: 'input_text.message', idleAction: 'set' },
|
||||||
|
{ id: 'input_button.stop', idleAction: 'press' },
|
||||||
|
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
|
||||||
|
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
|
||||||
|
], {
|
||||||
|
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
||||||
|
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
||||||
|
});
|
||||||
|
h.locked.add('switch.fan');
|
||||||
|
const result = await h.actions.runIdleActions();
|
||||||
|
assert.equal(result.results[0].ok, false);
|
||||||
|
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
|
||||||
|
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an outstanding service response does not block later commands', async () => {
|
||||||
|
const h = harness([{ id: 'switch.fan' }], { 'switch.fan': raw('off') });
|
||||||
|
const completions = [];
|
||||||
|
h.ha.callHomeAssistantService = () => new Promise((resolve) => { completions.push(resolve); });
|
||||||
|
const first = h.actions.act('switch.fan', 'on', user);
|
||||||
|
const second = h.actions.act('switch.fan', 'off', user);
|
||||||
|
// Both commands reach HA before either response arrives.
|
||||||
|
assert.equal(completions.length, 2);
|
||||||
|
completions.forEach((finish) => finish());
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locks are process-local and names resolve without guessing', () => {
|
||||||
|
const locks = createLocks();
|
||||||
|
locks.setLocked('number.fan', true);
|
||||||
|
assert.equal(locks.isLocked('number.fan'), true);
|
||||||
|
// A fresh service starts unlocked instead of restoring previous moderation.
|
||||||
|
assert.equal(createLocks().isLocked('number.fan'), false);
|
||||||
|
locks.setLocked('number.fan', false);
|
||||||
|
assert.equal(locks.isLocked('number.fan'), false);
|
||||||
|
const items = [{ id: 'number.fan', name: 'Fan speed' }, { id: 'number.other', name: 'Fan speed' }];
|
||||||
|
assert.equal(resolveItem(items.slice(0, 1), 'FAN SPEED').id, 'number.fan');
|
||||||
|
assert.equal(resolveItem(items, 'NUMBER.FAN').id, 'number.fan');
|
||||||
|
assert.throws(() => resolveItem(items, 'Fan speed'), /number.fan, number.other/);
|
||||||
|
// Keep the lookup error aligned with the panel name users see when choosing an item.
|
||||||
|
assert.throws(() => resolveItem(items, 'Fan'), /No activity control/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command preserves multiword names and exposes lock status', async () => {
|
||||||
|
const changes = [];
|
||||||
|
const replies = [];
|
||||||
|
const handler = createHaCommand({ config: { commands: { prefix: 'rs' } }, homeAssistantActivitiesService: {
|
||||||
|
setLocked: (name, locked) => { changes.push([name, locked]); return { id: 'number.fan', name }; },
|
||||||
|
getState: () => ({ items: [{ id: 'number.fan', name: 'Fan speed', locked: true }] }),
|
||||||
|
} });
|
||||||
|
const message = { reply: (reply) => { replies.push(reply.content); } };
|
||||||
|
await handler(message, ['lock', 'Fan', 'speed']);
|
||||||
|
await handler(message, ['status']);
|
||||||
|
assert.deepEqual(changes, [['Fan speed', true]]);
|
||||||
|
assert.match(replies[1], /Fan speed \(number.fan\): locked/);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user