mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 11:12:16 -05:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71267873a | ||
|
|
1ba4ebe5ff | ||
|
|
607d327e9c | ||
|
|
1bdca60648 | ||
|
|
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 |
+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 f -exec chmod 0644 {} +
|
||||
|
||||
FROM architecture-check AS learning-dependencies
|
||||
# CPU-only learning dependencies are built once. Disabled installations never
|
||||
# launch Python or install packages as a side effect of starting Node.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
|
||||
&& dnf clean all
|
||||
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
|
||||
RUN python3 -m venv /opt/rover-learning \
|
||||
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||
ARG FEDORA_VERSION
|
||||
ARG TARGETARCH
|
||||
@@ -148,6 +157,7 @@ COPY --from=webui-build /build/server/public ./public
|
||||
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
||||
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||
COPY --from=packaged-tools /output/ /
|
||||
COPY --from=learning-dependencies /opt/rover-learning /opt/rover-learning
|
||||
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
|
||||
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CPU benchmark of published NoMaD exploration, without ROS or rover connections.
|
||||
|
||||
Run: python3 perf/nomad-benchmark.py --setup
|
||||
Subsequent runs: python3 perf/nomad-benchmark.py
|
||||
Quick run: python3 perf/nomad-benchmark.py --threads 4 --samples 1 --seconds 2 --iterations 2
|
||||
|
||||
--setup installs CPU dependencies in a private cached venv and downloads pinned
|
||||
upstream source plus the official checkpoint. Nothing touches server packages.
|
||||
Synthetic images measure compute, NOT navigation quality. Excludes capture,
|
||||
video decode, network, physical control, training, and concurrent rover load.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import resource
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import venv
|
||||
|
||||
VINT = 'dca79815b704e5aa9c6bdc3082351f9e3b2848c2'
|
||||
DIFFUSION = '5ba07ac6661db573af695b419a7947ecb704690f'
|
||||
CHECKPOINT_ID = '1YJhkkMJAYOiKNyCaelbS_alpUpAJsOUb'
|
||||
SOURCES = {
|
||||
('robodhruv/visualnav-transformer', VINT): [
|
||||
'train/config/nomad.yaml', 'train/vint_train/data/data_config.yaml',
|
||||
'train/vint_train/models/nomad/nomad.py',
|
||||
'train/vint_train/models/nomad/nomad_vint.py',
|
||||
'train/vint_train/models/vint/self_attention.py',
|
||||
],
|
||||
('real-stanford/diffusion_policy', DIFFUSION): [
|
||||
'diffusion_policy/model/diffusion/conditional_unet1d.py',
|
||||
'diffusion_policy/model/diffusion/conv1d_components.py',
|
||||
'diffusion_policy/model/diffusion/positional_embedding.py',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def positive(value):
|
||||
result = int(value)
|
||||
if result < 1:
|
||||
raise argparse.ArgumentTypeError('Must be positive')
|
||||
return result
|
||||
|
||||
|
||||
def arguments():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument('--setup', action='store_true')
|
||||
p.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-nomad-benchmark')
|
||||
p.add_argument('--threads', type=positive, nargs='+', default=[4, 8, 16])
|
||||
p.add_argument('--samples', type=positive, nargs='+', default=[1, 8], help='Candidate trajectories per decision, not rover count')
|
||||
p.add_argument('--seconds', type=positive, default=10)
|
||||
p.add_argument('--iterations', type=positive, default=10)
|
||||
p.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
|
||||
p.add_argument('--result', type=Path, help=argparse.SUPPRESS)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def download(url, path):
|
||||
if path.exists():
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + '.part')
|
||||
print('Downloading', path.name, flush=True)
|
||||
with urllib.request.urlopen(url, timeout=120) as response, temporary.open('wb') as output:
|
||||
import shutil
|
||||
shutil.copyfileobj(response, output)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def prepare(args):
|
||||
python = args.cache / 'venv/bin/python'
|
||||
if not python.exists():
|
||||
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
|
||||
subprocess.run([str(python), '-m', 'pip', 'install', 'torch', 'torchvision',
|
||||
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
|
||||
subprocess.run([str(python), '-m', 'pip', 'install', 'numpy', 'diffusers',
|
||||
'efficientnet-pytorch', 'einops', 'PyYAML', 'gdown'], check=True)
|
||||
for (repo, revision), paths in SOURCES.items():
|
||||
for path in paths:
|
||||
download(f'https://raw.githubusercontent.com/{repo}/{revision}/{path}', args.cache / 'source' / revision / path)
|
||||
checkpoint = args.cache / 'nomad.pth'
|
||||
if not checkpoint.exists():
|
||||
temporary = checkpoint.with_suffix('.part')
|
||||
subprocess.run([str(python), '-m', 'gdown', CHECKPOINT_ID, '-O', str(temporary)], check=True)
|
||||
temporary.replace(checkpoint)
|
||||
|
||||
|
||||
def worker(args):
|
||||
import numpy as np
|
||||
import torch
|
||||
import yaml
|
||||
from diffusers.schedulers.scheduling_ddpm import DDPMScheduler
|
||||
sys.path[:0] = [str(args.cache / 'source' / VINT / 'train'), str(args.cache / 'source' / DIFFUSION)]
|
||||
from vint_train.models.nomad.nomad import NoMaD, DenseNetwork
|
||||
from vint_train.models.nomad.nomad_vint import NoMaD_ViNT, replace_bn_with_gn
|
||||
from diffusion_policy.model.diffusion.conditional_unet1d import ConditionalUnet1D
|
||||
|
||||
threads, samples = args.threads[0], args.samples[0]
|
||||
torch.set_num_threads(threads)
|
||||
torch.set_num_interop_threads(1)
|
||||
torch.manual_seed(0)
|
||||
c = yaml.safe_load((args.cache / 'source' / VINT / 'train/config/nomad.yaml').read_text())
|
||||
vision = replace_bn_with_gn(NoMaD_ViNT(
|
||||
context_size=c['context_size'], obs_encoding_size=c['encoding_size'],
|
||||
mha_num_attention_heads=c['mha_num_attention_heads'],
|
||||
mha_num_attention_layers=c['mha_num_attention_layers'], mha_ff_dim_factor=c['mha_ff_dim_factor']))
|
||||
model = NoMaD(vision, ConditionalUnet1D(input_dim=2, global_cond_dim=c['encoding_size'],
|
||||
down_dims=c['down_dims'], cond_predict_scale=c['cond_predict_scale']), DenseNetwork(c['encoding_size'])).eval()
|
||||
# Strict loading prevents accidentally benchmarking a partly random model.
|
||||
model.load_state_dict(torch.load(args.cache / 'nomad.pth', map_location='cpu', weights_only=True), strict=True)
|
||||
scheduler = DDPMScheduler(num_train_timesteps=c['num_diffusion_iters'],
|
||||
beta_schedule='squaredcos_cap_v2', clip_sample=True, prediction_type='epsilon')
|
||||
width, height = c['image_size']
|
||||
frames = torch.rand(c['context_size'] + 1, 3, height, width)
|
||||
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
|
||||
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
|
||||
mask = torch.ones(1, dtype=torch.long)
|
||||
stats = yaml.safe_load((args.cache / 'source' / VINT / 'train/vint_train/data/data_config.yaml').read_text())['action_stats']
|
||||
low, high = np.array(stats['min']), np.array(stats['max'])
|
||||
|
||||
def decision():
|
||||
start = time.perf_counter()
|
||||
obs = ((frames - mean) / std).reshape(1, -1, height, width)
|
||||
goal = torch.randn(1, 3, height, width)
|
||||
cond = model('vision_encoder', obs_img=obs, goal_img=goal, input_goal_mask=mask).repeat(samples, 1)
|
||||
encoded = time.perf_counter()
|
||||
action = torch.randn(samples, c['len_traj_pred'], 2)
|
||||
scheduler.set_timesteps(c['num_diffusion_iters'])
|
||||
for step in scheduler.timesteps:
|
||||
noise = model('noise_pred_net', sample=action, timestep=step, global_cond=cond)
|
||||
action = scheduler.step(noise, step, action).prev_sample
|
||||
# Match upstream get_action: unnormalize deltas then accumulate waypoints.
|
||||
waypoints = np.cumsum((action.numpy() + 1) / 2 * (high - low) + low, axis=1)
|
||||
if not np.isfinite(waypoints).all():
|
||||
raise RuntimeError('Non-finite model output')
|
||||
end = time.perf_counter()
|
||||
return (end - start) * 1000, (encoded - start) * 1000, (end - encoded) * 1000
|
||||
|
||||
rows = []
|
||||
with torch.inference_mode():
|
||||
for _ in range(3):
|
||||
decision()
|
||||
start, cpu = time.perf_counter(), time.process_time()
|
||||
while len(rows) < args.iterations or time.perf_counter() - start < args.seconds:
|
||||
rows.append(decision())
|
||||
elapsed, cpu = time.perf_counter() - start, time.process_time() - cpu
|
||||
total = sorted(r[0] for r in rows)
|
||||
result = dict(threads=threads, candidateTrajectories=samples, iterations=len(rows),
|
||||
p50Ms=statistics.median(total), p95Ms=total[math.ceil(.95 * len(total)) - 1], maxMs=max(total),
|
||||
decisionsPerSecond=len(rows)/elapsed, cpuCores=cpu/elapsed,
|
||||
peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||
visionMeanMs=statistics.mean(r[1] for r in rows), diffusionAndDecodeMeanMs=statistics.mean(r[2] for r in rows),
|
||||
parameters=sum(p.numel() for p in model.parameters()), diffusionSteps=c['num_diffusion_iters'],
|
||||
imageSize=c['image_size'], frames=c['context_size']+1,
|
||||
versions={name: importlib.metadata.version(name) for name in ['torch','torchvision','diffusers','numpy','efficientnet-pytorch','einops']})
|
||||
args.result.write_text(json.dumps(result))
|
||||
|
||||
|
||||
def main():
|
||||
args = arguments()
|
||||
args.cache = args.cache.expanduser().resolve()
|
||||
if args.worker:
|
||||
worker(args)
|
||||
return
|
||||
if args.setup:
|
||||
prepare(args)
|
||||
python = args.cache / 'venv/bin/python'
|
||||
if not python.exists() or not (args.cache / 'nomad.pth').exists():
|
||||
raise RuntimeError('First run: python3 perf/nomad-benchmark.py --setup')
|
||||
print('CPU FP32 NoMaD exploration; synthetic images; no driving-quality measurement.', flush=True)
|
||||
print('Candidates are trajectories for ONE rover. Includes vision + all 10 diffusion steps.', flush=True)
|
||||
print('threads candidates p50 ms p95 ms max ms decisions/s CPU cores peak MiB', flush=True)
|
||||
results = []
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix='nomad-bench-') as directory:
|
||||
for threads in args.threads:
|
||||
for samples in args.samples:
|
||||
path = Path(directory) / 'result.json'
|
||||
subprocess.run([str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
|
||||
'--threads', str(threads), '--samples', str(samples), '--seconds', str(args.seconds),
|
||||
'--iterations', str(args.iterations), '--result', str(path)], check=True,
|
||||
timeout=max(600, args.seconds*20), env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
|
||||
r = json.loads(path.read_text()); results.append(r)
|
||||
print(f"{threads:7} {samples:10} {r['p50Ms']:7.1f} {r['p95Ms']:7.1f} {r['maxMs']:7.1f} {r['decisionsPerSecond']:11.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
|
||||
with (args.cache / 'nomad.pth').open('rb') as f:
|
||||
digest = hashlib.file_digest(f, 'sha256').hexdigest()
|
||||
report = dict(benchmark='nomad-cpu-v1', platform=platform.platform(), logicalCpus=os.cpu_count(),
|
||||
loadAverage=os.getloadavg(), sourceRevisions=dict(vint=VINT,diffusion=DIFFUSION), checkpointSha256=digest,
|
||||
exclusions=['capture','video decode','network','physical control','training','concurrent model workloads'], results=results)
|
||||
print('\nPaste this report back:\n' + json.dumps(report, separators=(',', ':')))
|
||||
print('At 10 decisions/s the total budget is 100ms; at 5/s it is 200ms. Leave room for the excluded pipeline.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
print(f'Benchmark failed: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -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.")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@ require('./src/services/batteryManager');
|
||||
// subscriptions see fully decoded state without becoming an initialization
|
||||
// dependency of either control path.
|
||||
require('./src/services/fleetReportService');
|
||||
require('./src/services/roverLearningService');
|
||||
require('./src/services/replayEngineV2');
|
||||
// Replay delivery is a core service. It must subscribe before the optional
|
||||
// Discord feature so web requests always have a local delivery path.
|
||||
|
||||
+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};
|
||||
//# sourceMappingURL=SetupApp-CaWv689i.js.map
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-DN7XBkOe.js.map
|
||||
+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" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-n0JxE1Mv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-xCRVLGjq.css">
|
||||
<script type="module" crossorigin src="/assets/index-dJH5nUWV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DOmNOCX4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -184,6 +184,7 @@ test('service definitions generate public feature paths without a separate regis
|
||||
{ key: 'discord', path: ['discord', 'enabled'] },
|
||||
{ key: 'socials', path: ['socials', 'enabled'] },
|
||||
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
||||
{ key: 'roverLearning', path: ['roverLearning', 'enabled'] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const barcodeScanner = require('../services/barcodeScannerService/configuration'
|
||||
const commands = require('../services/operatorCommandService/configuration');
|
||||
const discord = require('../services/discordBotService/configuration');
|
||||
const fleetReports = require('../services/fleetReportService/configuration');
|
||||
const roverLearning = require('../services/roverLearningService/configuration');
|
||||
|
||||
/*
|
||||
Object property order is preserved by JSON serialization and JSON Schema
|
||||
@@ -55,6 +56,7 @@ const definitions = [
|
||||
sessionConfiguration.socials,
|
||||
sessionConfiguration.driverAd,
|
||||
fleetReports,
|
||||
roverLearning,
|
||||
];
|
||||
|
||||
const defaultConfig = Object.fromEntries(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Configuration Database Migrations
|
||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
||||
// Scope: Upgrades database structure and stored configuration documents before current-schema validation.
|
||||
const migrations = [
|
||||
{
|
||||
version: 1,
|
||||
@@ -72,6 +72,46 @@ 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();
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
run(db) {
|
||||
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
|
||||
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
|
||||
// Upgrade history as well as the active revision so a settings rollback
|
||||
// cannot reintroduce fields rejected by the new Automations schema.
|
||||
for (const row of rows) {
|
||||
const config = JSON.parse(row.config_json);
|
||||
let changed = false;
|
||||
for (const item of config.homeAssistantActivities?.items || []) {
|
||||
const hasAction = Object.hasOwn(item, 'idleAction');
|
||||
const hasValue = Object.hasOwn(item, 'idleValue');
|
||||
if (!hasAction && !hasValue) continue;
|
||||
item.automations ??= {};
|
||||
item.automations.idle ??= {};
|
||||
// Preserve any explicitly saved new-format setting when both formats
|
||||
// exist, including an empty text value used to clear an entity.
|
||||
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
|
||||
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
|
||||
delete item.idleAction;
|
||||
delete item.idleValue;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) update.run(JSON.stringify(config), row.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function applySchemaMigrations(db) {
|
||||
|
||||
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
|
||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getOperatingMode } = require('../operatingModeService');
|
||||
const { compareRoversForAssignment } = require('./roverRanking');
|
||||
|
||||
const socketRefs = new Map(); // socketId -> socket
|
||||
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
||||
});
|
||||
|
||||
function assignSocket(socket, options = {}) {
|
||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||
return;
|
||||
}
|
||||
// avoid double assignment
|
||||
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
|
||||
|
||||
module.exports = {
|
||||
assignmentEvents,
|
||||
resumeAssignment: assignSocket,
|
||||
releaseForOperatingMode: (socket) => {
|
||||
// Include direct admin control as well as automatic assignment ownership.
|
||||
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
|
||||
const assigned = assignments.get(socket.id);
|
||||
if (assigned) roverIds.add(assigned);
|
||||
waiting.delete(socket.id);
|
||||
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
|
||||
},
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
forceReleaseWithNotice,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
if (!socket || !roverId) return;
|
||||
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
|
||||
const previous = assignments.get(socket.id);
|
||||
if (previous && previous !== roverId && releasePrevious) {
|
||||
roverManager.releaseControl(previous, socket);
|
||||
|
||||
@@ -2,20 +2,14 @@
|
||||
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
||||
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../../globals/io');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRole } = require('../roleService');
|
||||
const { getOperatingMode } = require('../operatingModeService');
|
||||
const { describeAssignment } = require('../assignmentService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
|
||||
function resolvePtzChatTarget(socketId) {
|
||||
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
||||
}
|
||||
|
||||
function resolveRoverId(socketId) {
|
||||
const ptzTarget = resolvePtzChatTarget(socketId);
|
||||
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
if (primary) return primary;
|
||||
const assignment = describeAssignment(socketId);
|
||||
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
|
||||
|
||||
function resolveRoverColor(roverId) {
|
||||
if (!roverId) return null;
|
||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||
return ptzCameraService.getPublicState()?.color || null;
|
||||
}
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function resolveRoverName(roverId) {
|
||||
if (!roverId) return null;
|
||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||
return ptzCameraService.getPublicState()?.name || null;
|
||||
}
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.name || null;
|
||||
}
|
||||
|
||||
function isPtzChatTargetId(roverId) {
|
||||
/*
|
||||
PTZ is intentionally treated as a virtual rover for chat identity only. It
|
||||
does not live in roverManager.rovers because movement, video authorization,
|
||||
and queue ownership are PTZ-service concerns, but chat needs one stable
|
||||
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
|
||||
code can all render the same badge without learning PTZ internals.
|
||||
*/
|
||||
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
|
||||
}
|
||||
|
||||
function isPublicChatTargetId(roverId, socket = null) {
|
||||
if (!roverId) return false;
|
||||
/*
|
||||
Normal rovers remain governed by the existing replay visibility rule, which
|
||||
is also the rule chat historically used to avoid exposing closed private
|
||||
rover activity. PTZ gets an explicit allow-list entry here because it is a
|
||||
public chat target that deliberately pretends to be a rover, even though it
|
||||
is not a roverManager record.
|
||||
*/
|
||||
if (isPtzChatTargetId(roverId)) return true;
|
||||
return roverManager.canReplayRoverId(roverId, socket) === true;
|
||||
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
if (!roverId) return false;
|
||||
/*
|
||||
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
|
||||
but it is not a private rover. Let PTZ-badged messages broadcast normally
|
||||
instead of falling into the closed-private rover path for unknown ids.
|
||||
*/
|
||||
return !isPublicChatTargetId(roverId);
|
||||
return Boolean(roverId) && !isPublicChatTargetId(roverId);
|
||||
}
|
||||
|
||||
function normalizeProfileImageUrl(value) {
|
||||
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
|
||||
socketId: socket?.id || null,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||
roverId,
|
||||
roverName,
|
||||
roverColor,
|
||||
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
|
||||
socketId,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||
roverId,
|
||||
roverName,
|
||||
roverColor,
|
||||
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
|
||||
|
||||
module.exports = {
|
||||
resolveRoverId,
|
||||
isPtzChatTargetId,
|
||||
isPublicChatTargetId,
|
||||
isPrivateClosedRoverId,
|
||||
buildRoverCtxSnapshot,
|
||||
|
||||
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
/*
|
||||
PTZ borrows the roverId field for chat badges, but it has no rover command
|
||||
channel. Skipping the song command here keeps PTZ chat from producing noisy
|
||||
"unknown rover" command attempts while still allowing the message itself to
|
||||
behave like rover chat everywhere else.
|
||||
*/
|
||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
||||
}
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
if (!ttsOptions || !message?.roverId) return;
|
||||
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||
if (!ttsOptions || !message) return;
|
||||
if (message.operatingMode === 'ptz') {
|
||||
/*
|
||||
PTZ has no rover websocket, but it does have a real speaker behind the
|
||||
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
||||
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!message.roverId) return;
|
||||
const record = roverManager.rovers.get(message.roverId);
|
||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
|
||||
@@ -11,6 +11,7 @@ const logger = require('../../globals/logger').child('commandService');
|
||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||
const { subscribeCommandRecording, observeCommand, recordCommandRequest } = require('./recording');
|
||||
|
||||
// Command observations are intentionally separate from the global event bus.
|
||||
// Drive and motor commands can run at control-loop frequency, and publishing
|
||||
@@ -96,6 +97,7 @@ function issueCommand(roverId, payload) {
|
||||
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
||||
const message = { ...normalizedPayload, id };
|
||||
record.ws.send(JSON.stringify(message));
|
||||
observeCommand({ phase: 'dispatched', roverId, commandId: id, command: normalizedPayload });
|
||||
pendingCommands.set(id, { roverId, ts: Date.now(), type: normalizedPayload.type });
|
||||
logger.info('Issued command', roverId, normalizedPayload.type, id);
|
||||
return id;
|
||||
@@ -130,6 +132,7 @@ function handleAck(msg) {
|
||||
const pending = pendingCommands.get(msg.id);
|
||||
if (!pending) return;
|
||||
pendingCommands.delete(msg.id);
|
||||
observeCommand({ phase: 'acknowledged', roverId: pending.roverId, commandId: msg.id, acknowledgement: msg });
|
||||
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
||||
io.emit('commandAck', {
|
||||
roverId: pending.roverId,
|
||||
@@ -255,145 +258,149 @@ module.exports = {
|
||||
getRecentDriveActivity,
|
||||
setDriveCooldown,
|
||||
commandEvents,
|
||||
subscribeCommandRecording,
|
||||
submitCommand,
|
||||
};
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
function handleCommand({ roverId, type, data } = {}, cb) {
|
||||
const reply = typeof cb === 'function' ? cb : () => {};
|
||||
try {
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
// Shared authorization/safety boundary for browser and service-owned control.
|
||||
function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
||||
const reply = typeof cb === 'function' ? cb : () => {};
|
||||
try {
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('type required');
|
||||
}
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
let payload = data ? { ...data } : {};
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
return;
|
||||
}
|
||||
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||
logger.info('Ignoring laser command while room lights are locked on', {
|
||||
socketId: socket.id,
|
||||
roverId,
|
||||
action: getLaserAction(payload),
|
||||
});
|
||||
reply({ ignored: true, reason: 'roomLightsLockedOn' });
|
||||
return;
|
||||
}
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isUpdateCommand = type === 'update';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (!isAdminSocket && isDeterred(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
/*
|
||||
Both structured song commands and raw Open Interface song payloads
|
||||
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
|
||||
and any future browser beeper without affecting unrelated driving.
|
||||
*/
|
||||
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
|
||||
throw new Error('Muted');
|
||||
}
|
||||
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
|
||||
// in the same explicit admin-only branch as reboot instead of relying on
|
||||
// drive ownership checks, because having a turn should not grant system
|
||||
// maintenance privileges.
|
||||
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect) {
|
||||
if (!isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('type required');
|
||||
}
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
let payload = data ? { ...data } : {};
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
return;
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
if (!isAdminSocket) {
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
}
|
||||
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||
logger.info('Ignoring laser command while room lights are locked on', {
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
if (left < 0 && right < 0) direction = 'backward';
|
||||
lastDriveActivity.set(roverId, {
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
roverId,
|
||||
action: getLaserAction(payload),
|
||||
});
|
||||
reply({ ignored: true, reason: 'roomLightsLockedOn' });
|
||||
return;
|
||||
}
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isUpdateCommand = type === 'update';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (!isAdminSocket && isDeterred(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
/*
|
||||
Both structured song commands and raw Open Interface song payloads
|
||||
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
|
||||
and any future browser beeper without affecting unrelated driving.
|
||||
*/
|
||||
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
|
||||
throw new Error('Muted');
|
||||
}
|
||||
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
|
||||
// in the same explicit admin-only branch as reboot instead of relying on
|
||||
// drive ownership checks, because having a turn should not grant system
|
||||
// maintenance privileges.
|
||||
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect) {
|
||||
if (!isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
}
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
if (!isAdminSocket) {
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
}
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
if (left < 0 && right < 0) direction = 'backward';
|
||||
lastDriveActivity.set(roverId, {
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
direction,
|
||||
speed,
|
||||
isAdmin: isAdminSocket,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'drive' || type === 'motors') {
|
||||
/*
|
||||
Role is supplied at the command boundary because telemetry does not
|
||||
identify the operator who produced the active motor intent. Admin and
|
||||
lockdown commands therefore enter the service explicitly bypassed;
|
||||
they are recorded for status visibility but are never scaled, blocked,
|
||||
or countermanded by a later sensor frame.
|
||||
*/
|
||||
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||
bypassed: isAdminSocket,
|
||||
direction,
|
||||
speed,
|
||||
isAdmin: isAdminSocket,
|
||||
});
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: String(roverId),
|
||||
type,
|
||||
commandId: id,
|
||||
outcome: 'issued',
|
||||
socketId: socket.id,
|
||||
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
|
||||
// commands can carry arbitrary content. Their structured type/outcome
|
||||
// supplies analytics without accidentally persisting secret material.
|
||||
});
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
if (shouldRecordTurnActivity(type, payload)) {
|
||||
try {
|
||||
const { recordActivity } = require('../turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// Activity recording is best-effort because command delivery should not fail if turn bookkeeping has a transient issue.
|
||||
}
|
||||
}
|
||||
reply({ id });
|
||||
} catch (err) {
|
||||
logger.warn('Command rejected', socket.id, err.message);
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: roverId ? String(roverId) : null,
|
||||
type: type || 'unknown',
|
||||
outcome: 'rejected',
|
||||
socketId: socket.id,
|
||||
error: err.message,
|
||||
});
|
||||
reply({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('command', handleCommand);
|
||||
socket.on('command:issue', handleCommand);
|
||||
if (type === 'drive' || type === 'motors') {
|
||||
/*
|
||||
Role is supplied at the command boundary because telemetry does not
|
||||
identify the operator who produced the active motor intent. Admin and
|
||||
lockdown commands therefore enter the service explicitly bypassed;
|
||||
they are recorded for status visibility but are never scaled, blocked,
|
||||
or countermanded by a later sensor frame.
|
||||
*/
|
||||
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||
bypassed: isAdminSocket,
|
||||
});
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: String(roverId),
|
||||
type,
|
||||
commandId: id,
|
||||
outcome: 'issued',
|
||||
socketId: socket.id,
|
||||
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
|
||||
// commands can carry arbitrary content. Their structured type/outcome
|
||||
// supplies analytics without accidentally persisting secret material.
|
||||
});
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
if (shouldRecordTurnActivity(type, payload)) {
|
||||
try {
|
||||
const { recordActivity } = require('../turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// Activity recording is best-effort because command delivery should not fail if turn bookkeeping has a transient issue.
|
||||
}
|
||||
}
|
||||
reply({ id });
|
||||
} catch (err) {
|
||||
logger.warn('Command rejected', socket.id, err.message);
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: roverId ? String(roverId) : null,
|
||||
type: type || 'unknown',
|
||||
outcome: 'rejected',
|
||||
socketId: socket.id,
|
||||
error: err.message,
|
||||
});
|
||||
reply({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const recordedCommand = (request, reply) => recordCommandRequest(socket, request, reply, (message, cb) => submitCommand(socket, message, cb));
|
||||
socket.on('command', recordedCommand);
|
||||
socket.on('command:issue', recordedCommand);
|
||||
|
||||
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
|
||||
const reply = typeof cb === 'function' ? cb : () => {};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Optional detailed command observation, separate from payload-free analytics.
|
||||
// Subscribers must only enqueue work; recorder failures cannot fail a command.
|
||||
const { AsyncLocalStorage } = require('node:async_hooks');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
|
||||
const observers = new Set();
|
||||
const requests = new AsyncLocalStorage();
|
||||
|
||||
function subscribeCommandRecording(observer) {
|
||||
observers.add(observer);
|
||||
return () => observers.delete(observer);
|
||||
}
|
||||
|
||||
function observeCommand(event) {
|
||||
if (!observers.size) return;
|
||||
const scope = requests.getStore();
|
||||
const context = scope?.active ? scope.context : null;
|
||||
for (const observer of observers) {
|
||||
try {
|
||||
// Snapshot before safety code or a caller mutates a nested payload. Each
|
||||
// subscriber receives its own copy and cannot change command delivery.
|
||||
observer(structuredClone({
|
||||
ts: Date.now(),
|
||||
monotonicNs: process.hrtime.bigint().toString(),
|
||||
source: context ? 'client' : 'server',
|
||||
...context,
|
||||
...event,
|
||||
}));
|
||||
} catch {
|
||||
// Recording is deliberately best-effort at this boundary. The owning
|
||||
// recorder reports its storage health independently of driver replies.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordCommandRequest(socket, request, reply, execute, source = 'client') {
|
||||
if (!observers.size) return execute(request, reply);
|
||||
const context = { requestId: randomUUID(), socketId: socket.id, userId: socket.data?.userId || null, source };
|
||||
const scope = { active: true, context };
|
||||
return requests.run(scope, () => {
|
||||
try {
|
||||
observeCommand({ phase: 'requested', roverId: request?.roverId, command: request });
|
||||
return execute(request, (result) => {
|
||||
observeCommand({ phase: 'result', roverId: request?.roverId, result });
|
||||
if (typeof reply === 'function') reply(result);
|
||||
});
|
||||
} finally {
|
||||
// The command handler is synchronous. A safety timer created inside it
|
||||
// inherits async context but its later commands are server actions.
|
||||
scope.active = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { subscribeCommandRecording, observeCommand, recordCommandRequest };
|
||||
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
|
||||
function handleChatBridgeOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
if (!payload) return;
|
||||
/*
|
||||
Outbound bridge filtering must use chat visibility, not rover replay
|
||||
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
|
||||
chat badge path can be reused, but that id is not a roverManager rover and
|
||||
would be dropped by canReplayRoverId().
|
||||
*/
|
||||
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
||||
const guildConfigs = listGuildConfigs();
|
||||
if (!guildConfigs.length) return;
|
||||
|
||||
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
|
||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||
}
|
||||
/*
|
||||
The chat payload already carries the resolved display name for rover-like
|
||||
targets. Prefer that name so PTZ, which is intentionally pretending to be a
|
||||
rover in chat, shows up as "PTZ Camera" instead of the internal id
|
||||
"ptz-camera"; fall back to the id for older payloads or missing metadata.
|
||||
*/
|
||||
const roverTagLabel = payload.roverName || payload.roverId;
|
||||
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
|
||||
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
|
||||
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
|
||||
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// User and idle commands share one dispatch contract, while only the trusted
|
||||
// idle entry point can bypass per-item user locks.
|
||||
// User and automated commands share validation; only trusted automation bypasses user permissions.
|
||||
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||
|
||||
function assertAccess(actor = {}) {
|
||||
@@ -9,13 +8,14 @@ function assertAccess(actor = {}) {
|
||||
}
|
||||
|
||||
function createActions({ getConfig, ha, locks }) {
|
||||
async function execute(id, value, actor, idle = false) {
|
||||
if (!idle) assertAccess(actor);
|
||||
async function execute(id, value, actor, automated = false) {
|
||||
if (!automated) assertAccess(actor);
|
||||
const config = getConfig();
|
||||
if (!config.enabled) throw new Error('Activity Controls are disabled');
|
||||
const item = config.items.find((entry) => entry.id === id);
|
||||
if (!item) throw new Error('Unknown activity item');
|
||||
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
|
||||
if (!automated && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
|
||||
if (!automated && item.readOnly && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is read-only for users');
|
||||
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
|
||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
||||
if (!entity.available) throw new Error('This item is unavailable');
|
||||
@@ -25,30 +25,34 @@ function createActions({ getConfig, ha, locks }) {
|
||||
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
|
||||
}
|
||||
|
||||
async function runIdleActions() {
|
||||
async function runAutomation(trigger, shouldRun = () => true) {
|
||||
const config = getConfig();
|
||||
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
|
||||
if (!config.enabled) return { action: trigger, skipped: true };
|
||||
const results = [];
|
||||
// Catch per item so one offline integration cannot prevent the remaining
|
||||
// configured cleanup actions from running during this idle window.
|
||||
// configured actions from running for this trigger.
|
||||
for (const item of config.items) {
|
||||
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
|
||||
// Stop remaining writes if presence or mode changed while HA was responding.
|
||||
if (!shouldRun()) break;
|
||||
const setting = item.automations?.[trigger];
|
||||
if (!setting?.action || setting.action === 'unchanged') continue;
|
||||
try {
|
||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
||||
if (entity.type === 'readOnly') continue;
|
||||
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
|
||||
if ((setting.action === 'press') !== (entity.type === 'button')) throw new Error('Automation action does not match the control type');
|
||||
// The admin form omits an empty optional string. Treat that as empty
|
||||
// text so clearing a message works; other types still reject it through
|
||||
// their normal value validation rather than silently receiving zero.
|
||||
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
|
||||
await execute(item.id, setting.action === 'press' ? 'press' : (setting.value ?? ''), null, true);
|
||||
results.push({ id: item.id, ok: true });
|
||||
} catch (error) {
|
||||
results.push({ id: item.id, ok: false, error: error.message });
|
||||
}
|
||||
}
|
||||
return { action: 'homeAssistantActivitiesIdle', results };
|
||||
return { action: trigger, results };
|
||||
}
|
||||
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
|
||||
return { act: (id, value, actor) => execute(id, value, actor), runAutomation,
|
||||
runIdleActions: () => runAutomation('idle') };
|
||||
}
|
||||
|
||||
module.exports = { createActions, assertAccess };
|
||||
|
||||
@@ -29,7 +29,7 @@ test('sensor states, live constraints, and unknown button states are preserved',
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
||||
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
|
||||
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
|
||||
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'toggle');
|
||||
});
|
||||
|
||||
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
|
||||
@@ -87,22 +87,22 @@ test('permissions, locks, availability and allowlist are authoritative', async (
|
||||
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
||||
const h = harness([
|
||||
{ id: 'switch.keep' },
|
||||
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
|
||||
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
|
||||
{ id: 'number.bad', automations: { idle: { action: 'set', value: '200' } } },
|
||||
{ id: 'switch.fan', automations: { idle: { action: 'set', value: 'off' } } },
|
||||
// An empty optional text box is omitted by the schema-driven admin form.
|
||||
{ id: 'input_text.message', idleAction: 'set' },
|
||||
{ id: 'input_button.stop', idleAction: 'press' },
|
||||
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
|
||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
|
||||
{ id: 'input_text.message', automations: { idle: { action: 'set' } } },
|
||||
{ id: 'input_button.stop', automations: { idle: { action: 'press' } } },
|
||||
{ id: 'sensor.humidity', automations: { idle: { action: 'set', value: '0' } } },
|
||||
{ id: 'switch.readonly', readOnly: true, automations: { idle: { action: 'set', value: 'off' } } },
|
||||
], {
|
||||
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
||||
'switch.readonly': raw('on'), 'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
||||
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
||||
});
|
||||
h.locked.add('switch.fan');
|
||||
const result = await h.actions.runIdleActions();
|
||||
assert.equal(result.results[0].ok, false);
|
||||
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
|
||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
|
||||
assert.equal(result.results.filter((entry) => entry.ok).length, 4);
|
||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }, { entity_id: 'switch.readonly' }]);
|
||||
});
|
||||
|
||||
test('an outstanding service response does not block later commands', async () => {
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
// never acquire unrelated devices simply because they share a connection.
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
|
||||
// All triggers use the same inputs so the admin form and execution contract agree.
|
||||
function automation(title, description) {
|
||||
return strictObject({
|
||||
action: string({ title: 'Action', enum: ['unchanged', 'set', 'press'], default: 'unchanged' }),
|
||||
value: string({ title: 'Value', description: 'For set: on/off, a number, exact selection, or text. Empty text clears it. Ignored for press.', maxLength: 255 }),
|
||||
}, { title, description });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
key: 'homeAssistantActivities',
|
||||
feature: true,
|
||||
@@ -18,9 +26,12 @@ module.exports = {
|
||||
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
|
||||
// Match social-link colors so admins can customize tiles through the existing config form.
|
||||
color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||
readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }),
|
||||
idleAction: string({ title: 'When idle', enum: ['unchanged', 'set', 'press'], default: 'unchanged', description: 'Leave unchanged, set the idle value, or press a button once. Runs independently of user locks.' }),
|
||||
idleValue: string({ title: 'Idle value', description: 'For set: on/off, a number, exact selection, or text (empty text clears it). Checked against live entity limits when idle runs.', examples: ['0'], maxLength: 255 }),
|
||||
readOnly: boolean({ title: 'Read only for users', default: false, description: 'Ordinary users can only view this item. Admins and automations can still change supported controls.' }),
|
||||
automations: strictObject({
|
||||
idle: automation('When idle', 'Runs after the idle timeout with no users or admins online.'),
|
||||
locked: automation('When server locked', 'Runs on entering admin or lockdown mode.'),
|
||||
online: automation('When users online', 'Runs when a user or admin comes online, or the server reopens with one online. Suppressed in admin and lockdown modes. Spectators do not count.'),
|
||||
}, { title: 'Automations', description: 'One-time actions that bypass item locks and read-only permissions.' }),
|
||||
}, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }),
|
||||
},
|
||||
}, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
|
||||
|
||||
@@ -9,7 +9,8 @@ const TYPES = {
|
||||
function buildEntity(item, raw, locked = false) {
|
||||
const attributes = raw?.attributes || {};
|
||||
const domain = item.id.split('.')[0];
|
||||
const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly';
|
||||
// Read-only is a user permission, not an entity capability: admins and automation still need its write type.
|
||||
const type = TYPES[domain] || 'readOnly';
|
||||
// A never-pressed button legitimately reports unknown. Its state is a last
|
||||
// press timestamp, not availability or a boolean toggle state.
|
||||
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
|
||||
@@ -17,7 +18,7 @@ function buildEntity(item, raw, locked = false) {
|
||||
return {
|
||||
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
|
||||
// Include presentation settings in session state so all clients share the configured color.
|
||||
icon: item.icon || '', color: item.color || '', domain, type, locked, available,
|
||||
icon: item.icon || '', color: item.color || '', domain, type, readOnly: Boolean(item.readOnly), locked, available,
|
||||
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
|
||||
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
|
||||
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
|
||||
|
||||
@@ -8,7 +8,7 @@ const { buildEntity } = require('./entityHelpers');
|
||||
const { createLocks, resolveItem } = require('./locks');
|
||||
const { createActions } = require('./actions');
|
||||
const { getRole } = require('../roleService');
|
||||
const { getMode } = require('../modeManager');
|
||||
const { getMode, modeEvents } = require('../modeManager');
|
||||
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
|
||||
|
||||
const events = new EventEmitter();
|
||||
@@ -24,6 +24,35 @@ const actions = createActions({ getConfig: () => config, ha: {
|
||||
callHomeAssistantService: ha.callHomeAssistantService,
|
||||
}, locks });
|
||||
|
||||
let operatorsOnline = false;
|
||||
let lastAutomationState = null;
|
||||
let automationRevision = 0;
|
||||
|
||||
function refreshAutomation() {
|
||||
const mode = getMode();
|
||||
const locked = mode === 'admin' || mode === 'lockdown';
|
||||
// Remember transitions, not entity values: HA updates and extra connections
|
||||
// must not repeatedly press buttons or undo a manual admin adjustment.
|
||||
const state = config.enabled && snapshotReady && ha.isConnected()
|
||||
? (locked ? mode : operatorsOnline ? 'online' : null) : null;
|
||||
if (state === lastAutomationState) return;
|
||||
lastAutomationState = state;
|
||||
const revision = ++automationRevision;
|
||||
if (!state) return;
|
||||
actions.runAutomation(locked ? 'locked' : 'online', () => revision === automationRevision)
|
||||
.then((result) => {
|
||||
const failures = result.results?.filter((item) => !item.ok) || [];
|
||||
if (failures.length) logger.warn('Activity automation failed', { trigger: result.action, failures });
|
||||
}).catch((error) => logger.warn('Activity automation failed', { error: error.message }));
|
||||
}
|
||||
|
||||
function setOperatorsOnline(online) {
|
||||
operatorsOnline = online;
|
||||
refreshAutomation();
|
||||
}
|
||||
|
||||
modeEvents.on('change', refreshAutomation);
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
@@ -53,14 +82,17 @@ function setLocked(query, locked) {
|
||||
ha.homeAssistantEvents.on('snapshot', () => {
|
||||
snapshotReady = true;
|
||||
emitUpdate();
|
||||
refreshAutomation();
|
||||
});
|
||||
ha.homeAssistantEvents.on('status', () => {
|
||||
snapshotReady = false;
|
||||
emitUpdate();
|
||||
refreshAutomation();
|
||||
});
|
||||
registerConfigurationHandler('homeAssistantActivities', (next) => {
|
||||
config = next;
|
||||
emitUpdate();
|
||||
refreshAutomation();
|
||||
});
|
||||
// Resolve access on the server for every command; clients only supply item/value.
|
||||
io.on('connection', (socket) => {
|
||||
@@ -76,4 +108,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = { ...actions, getState, setLocked, activityEvents: events };
|
||||
module.exports = { ...actions, getState, setLocked, setOperatorsOnline, activityEvents: events,
|
||||
// A returning operator ends idle cleanup even if earlier HA calls are still settling.
|
||||
runIdleActions: () => actions.runAutomation('idle', () => !operatorsOnline),
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
|
||||
const { IDLE_TIMEOUT_MS } = require('./constants');
|
||||
const { runtime } = require('./state');
|
||||
const { runIdleActions } = require('./actions');
|
||||
const activityControls = require('../homeAssistantActivitiesService');
|
||||
|
||||
function getActivitySnapshot() {
|
||||
let onlineUsers = 0;
|
||||
@@ -113,6 +114,8 @@ function scheduleIdleTimer() {
|
||||
function refreshIdleState() {
|
||||
const activity = getActivitySnapshot();
|
||||
logger.info('Idle state refresh', activity);
|
||||
// Share the exact idle presence policy; Activity Controls must not count spectators or invent a second role filter.
|
||||
activityControls.setOperatorsOnline(activity.totalActive > 0);
|
||||
if (activity.totalActive > 0) {
|
||||
clearIdleTimer();
|
||||
if (runtime.idleActionsCompleted) {
|
||||
|
||||
@@ -10,7 +10,8 @@ const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('.
|
||||
const { getConfiguredSocials } = require('../sessionService/configuration');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getTurnQueues } = require('../turnService');
|
||||
const { getTurnQueues, getActiveDrivers } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
||||
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
||||
const { getRole } = require('../roleService');
|
||||
@@ -157,22 +158,6 @@ function publicRoverIdSet(roster = []) {
|
||||
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
||||
}
|
||||
|
||||
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
|
||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
||||
const next = {};
|
||||
/*
|
||||
RoverQueuesPanel creates fallback rows for queue ids that are not present in
|
||||
the roster, so the public payload must filter queues with the exact same
|
||||
privacy boundary as the roster. Otherwise a closed-private rover can leak as
|
||||
an orphan queue row after a private access grant assigns someone to it.
|
||||
*/
|
||||
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
|
||||
if (!visible.has(String(roverId))) return;
|
||||
next[roverId] = info;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function filterPublicUsers(users = [], publicIds) {
|
||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
||||
/*
|
||||
@@ -203,6 +188,8 @@ function buildLocalInfo() {
|
||||
const publicIds = publicRoverIdSet(publicRoster);
|
||||
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
||||
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
||||
const turnQueues = getTurnQueues();
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
||||
return {
|
||||
instance: {
|
||||
@@ -212,8 +199,12 @@ function buildLocalInfo() {
|
||||
features,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
roster,
|
||||
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
|
||||
// Only public roster entries reach the builder; queue metadata follows the
|
||||
// same visibility boundary instead of creating separate orphan queue rows.
|
||||
roster: roster.map((rover) => ({
|
||||
...rover,
|
||||
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
|
||||
})),
|
||||
users,
|
||||
roomCameras,
|
||||
socials: features.socials ? getConfiguredSocials(config) : [],
|
||||
@@ -349,7 +340,6 @@ function normalizeRemotePayload(entry, payload) {
|
||||
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
||||
},
|
||||
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
||||
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
|
||||
users: Array.isArray(payload?.users) ? payload.users : [],
|
||||
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
||||
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
||||
@@ -419,7 +409,6 @@ function markOffline(entry, error) {
|
||||
publicUrl: previous.instance?.publicUrl || entry.url,
|
||||
},
|
||||
roster: previous.roster || [],
|
||||
turnQueues: previous.turnQueues || {},
|
||||
users: previous.users || [],
|
||||
roomCameras: previous.roomCameras || [],
|
||||
socials: previous.socials || [],
|
||||
|
||||
@@ -370,11 +370,6 @@ function createSnapshotEngine(deps) {
|
||||
.filter((entry) => {
|
||||
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
||||
if (!roverId) return true;
|
||||
/*
|
||||
This is a chat transcript filter, not a physical-rover filter. PTZ
|
||||
chat intentionally carries a rover-like id so transcript consumers can
|
||||
render it consistently, even though roverManager cannot replay that id.
|
||||
*/
|
||||
return isPublicChatTargetId(roverId);
|
||||
});
|
||||
const chatRecent = allRecentMessages
|
||||
@@ -430,6 +425,8 @@ function createSnapshotEngine(deps) {
|
||||
type: 'chat',
|
||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||
text: entry.text || '',
|
||||
operating_mode: entry.operatingMode || null,
|
||||
operating_mode_name: entry.operatingModeDisplay?.name || null,
|
||||
rover_id: roverId,
|
||||
rover_ctx: { ...baseCtx, ...storedCtx },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const operatingModeEvents = new EventEmitter();
|
||||
const transitions = new WeakMap();
|
||||
|
||||
function getOperatingMode(socket) {
|
||||
// Handshake intent reserves the mode before role-driven assignment runs.
|
||||
// It grants no camera permissions; entry still checks access and readiness.
|
||||
return socket?.data?.operatingMode
|
||||
|| (socket?.handshake?.auth?.operatingMode === 'ptz' ? 'ptz' : 'rover');
|
||||
}
|
||||
|
||||
async function transition(socket, operatingMode) {
|
||||
// Resolve services at the action boundary to avoid a dependency cycle with
|
||||
// assignment and camera policy, which both read operating mode.
|
||||
const assignment = require('../assignmentService');
|
||||
const roverManager = require('../roverManager');
|
||||
const ptz = require('../ptzCameraService');
|
||||
if (operatingMode === 'rover' && getOperatingMode(socket) === 'rover') {
|
||||
return { operatingMode, state: ptz.getPublicState(socket) };
|
||||
}
|
||||
if (operatingMode === 'ptz') {
|
||||
await ptz.prepareParticipation(socket);
|
||||
if (socket.disconnected) throw new Error('Session disconnected');
|
||||
ptz.checkParticipationAccess(socket);
|
||||
const leave = roverManager.canLeaveCurrentRover(socket);
|
||||
if (!leave.ok) throw new Error(leave.message);
|
||||
|
||||
// Block new rover ownership before release events notify other services.
|
||||
socket.data.operatingMode = 'ptz';
|
||||
assignment.releaseForOperatingMode(socket);
|
||||
socket.data.ptzEntered = true;
|
||||
} else {
|
||||
ptz.releaseTurn(socket);
|
||||
socket.data.ptzEntered = false;
|
||||
socket.data.operatingMode = 'rover';
|
||||
assignment.resumeAssignment(socket);
|
||||
}
|
||||
operatingModeEvents.emit('change', { socket, operatingMode });
|
||||
return { operatingMode, state: ptz.getPublicState(socket) };
|
||||
}
|
||||
|
||||
function enqueueTransition(socket, action) {
|
||||
// Camera initialization can yield. Serialize switches and turn release per
|
||||
// socket so a late entry cannot undo a subsequent exit or release request.
|
||||
const previous = transitions.get(socket) || Promise.resolve();
|
||||
const next = previous.catch(() => {}).then(() => {
|
||||
if (socket.disconnected) throw new Error('Session disconnected');
|
||||
return action();
|
||||
});
|
||||
transitions.set(socket, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function setOperatingMode(socket, operatingMode) {
|
||||
if (!['rover', 'ptz'].includes(operatingMode)) {
|
||||
return Promise.reject(new Error('Invalid operating mode'));
|
||||
}
|
||||
return enqueueTransition(socket, () => transition(socket, operatingMode));
|
||||
}
|
||||
|
||||
function runPtzTurnAction(socket, action) {
|
||||
return enqueueTransition(socket, () => {
|
||||
const ptz = require('../ptzCameraService');
|
||||
if (action === 'release') return ptz.releaseTurn(socket);
|
||||
if (action !== 'request') throw new Error('Invalid PTZ turn action');
|
||||
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
|
||||
throw new Error('Enter PTZ before requesting a turn');
|
||||
}
|
||||
return ptz.claimTurn(socket);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
|
||||
@@ -396,11 +396,6 @@ async function runDecision(triggerReason) {
|
||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||
.filter((entry) => {
|
||||
if (!entry?.roverId) return true;
|
||||
/*
|
||||
Chat context should preserve every public chat target, including the
|
||||
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
|
||||
it is owned by ptzCameraService instead of roverManager.
|
||||
*/
|
||||
return isPublicChatTargetId(entry.roverId);
|
||||
})
|
||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
||||
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
|
||||
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
||||
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
||||
|
||||
let disposed = false;
|
||||
let playbackProc = null;
|
||||
let playbackSeq = 0;
|
||||
|
||||
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
async function playFile(filePath, context = {}) {
|
||||
if (!audioEnabled) throw new Error('PTZ audio disabled');
|
||||
async function playFile(filePath, context = {}, authorize = () => {}) {
|
||||
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
|
||||
authorize();
|
||||
const neolinkConfigPath = await ensureNeolinkConfig();
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
||||
|
||||
// Rendering and config IO may outlive a turn or camera configuration.
|
||||
if (disposed) throw new Error('PTZ audio configuration changed');
|
||||
authorize();
|
||||
stopActivePlayback('new-playback');
|
||||
const seq = ++playbackSeq;
|
||||
const args = [
|
||||
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
|
||||
return { pid: proc.pid || null, seq };
|
||||
}
|
||||
|
||||
async function speakText(text, ttsOptions = {}, context = {}) {
|
||||
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
|
||||
const rendered = await ensureTtsFile(text, ttsOptions);
|
||||
await playFile(rendered.filePath, {
|
||||
...context,
|
||||
engine: rendered.engine,
|
||||
cached: rendered.cached,
|
||||
});
|
||||
}, authorize);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
|
||||
getState,
|
||||
speakText,
|
||||
stopActivePlayback,
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
stopActivePlayback('configuration-change');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user