mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb7cc5bdc | ||
|
|
dbcb7f6a7a | ||
|
|
34936ab05c | ||
|
|
7231809140 | ||
|
|
8ca714cf1a | ||
|
|
ffd34de56c | ||
|
|
aeeff6fe1b | ||
|
|
846c9dae55 | ||
|
|
e750712a39 | ||
|
|
ff8b0c530e | ||
|
|
69c5bfe544 | ||
|
|
3ad95b376a | ||
|
|
bda740b08d | ||
|
|
3c8c5f5883 | ||
|
|
c35544e515 | ||
|
|
0df4a5ec89 | ||
|
|
8e301e7db7 | ||
|
|
b712fb89c6 | ||
|
|
fe2c29371f | ||
|
|
61ee692815 |
@@ -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.")
|
||||
+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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,12 +6,14 @@ const { MessageFlags } = require('discord.js');
|
||||
function createChannelIO({ client, logger, sanitizeMentions }) {
|
||||
const channelCache = new Map();
|
||||
const typingMessageCache = new Map();
|
||||
let stopped = false;
|
||||
|
||||
async function fetchChannel(id) {
|
||||
if (!id) return null;
|
||||
if (stopped || !client.isReady() || !id) return null;
|
||||
if (channelCache.has(id)) return channelCache.get(id);
|
||||
try {
|
||||
const channel = await client.channels.fetch(id);
|
||||
if (stopped) return null;
|
||||
if (channel) {
|
||||
channelCache.set(id, channel);
|
||||
return channel;
|
||||
@@ -68,6 +70,7 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
|
||||
const content = `-# *${username} is typing...*`;
|
||||
try {
|
||||
const message = await channel.send({ content, allowedMentions: { parse: [] }, flags: [MessageFlags.SuppressNotifications]});
|
||||
if (stopped) return;
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearTypingMessage(entry.guildId, typingId);
|
||||
}, 20000);
|
||||
@@ -78,6 +81,12 @@ function createChannelIO({ client, logger, sanitizeMentions }) {
|
||||
}
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
for (const record of typingMessageCache.values()) clearTimeout(record.timeoutId);
|
||||
typingMessageCache.clear();
|
||||
channelCache.clear();
|
||||
},
|
||||
fetchChannel,
|
||||
sendToChannel,
|
||||
clearTypingMessage,
|
||||
|
||||
@@ -18,6 +18,7 @@ module.exports = {
|
||||
adminAlerts: '123456789012345678',
|
||||
replay: '123456789012345678',
|
||||
humanAlerts: '123456789012345678',
|
||||
liveStatus: '',
|
||||
},
|
||||
roles: {
|
||||
stalkerPing: '123456789012345678',
|
||||
@@ -31,6 +32,7 @@ module.exports = {
|
||||
token: string({ title: 'Bot token', description: 'Discord bot token used to log in. The saved value is never returned to the browser.', examples: ['DISCORD_BOT_TOKEN'], writeOnly: true, maxLength: 10000 }),
|
||||
guildId: string({ title: 'Guild id', description: 'Reserved Discord server identifier. The current bot runtime does not restrict commands or events using this value.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
channels: strictObject({
|
||||
liveStatus: string({ title: 'Live status', description: 'Dedicated live fleet report channel ID. The bot deletes ALL other messages here. Requires View Channel, Read Message History, Send Messages, and Manage Messages. Leave empty to disable.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
general: string({ description: 'Channel ID used by the button-box stalker-role and everyone-ping rewards.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
announcements: string({ description: 'Channel ID used for public-mode openings, objective changes, and all-rovers-unlocked announcements.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
adminAlerts: string({ description: 'Channel ID used for rover health, battery, dock, help, and daily fleet-report notifications.', examples: ['123456789012345678'], maxLength: 100 }),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Owns connection replacement and recovery from failed initial logins.
|
||||
function createConnectionManager({ createRuntime, logger }) {
|
||||
let desired = { enabled: false, token: '' };
|
||||
let revision = 0;
|
||||
let runtime = null;
|
||||
let queue = Promise.resolve();
|
||||
let retryTimer = null;
|
||||
let retryDelay = 5000;
|
||||
|
||||
function enqueue(operation) {
|
||||
const pending = queue.then(operation);
|
||||
// Report failures without poisoning subsequent configuration changes.
|
||||
queue = pending.catch((err) => logger.error('Discord connection update failed', err.message));
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
const previous = runtime;
|
||||
runtime = null;
|
||||
if (!previous) return;
|
||||
previous.stop();
|
||||
// discord.js cannot cancel login while gateway discovery is pending.
|
||||
// Let that attempt settle before destroying it, otherwise it can open a
|
||||
// socket after destroy() has already run and leave an orphan connection.
|
||||
await previous.login;
|
||||
await previous.client.destroy();
|
||||
}
|
||||
|
||||
function scheduleRetry(version) {
|
||||
if (version !== revision || !desired.enabled) return;
|
||||
const delay = retryDelay;
|
||||
retryDelay = Math.min(retryDelay * 2, 60000);
|
||||
logger.warn('Retrying Discord login', { delayMs: delay });
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
enqueue(() => start(version));
|
||||
}, delay);
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
async function start(version) {
|
||||
if (version !== revision || !desired.enabled) return;
|
||||
await stop();
|
||||
if (version !== revision || !desired.enabled) return;
|
||||
const current = createRuntime();
|
||||
runtime = current;
|
||||
const { client } = current;
|
||||
client.on('error', (err) => logger.error('Discord client error', err.message));
|
||||
client.on('shardError', (err, shardId) => {
|
||||
logger.warn('Discord connection error', { shardId, error: err.message });
|
||||
});
|
||||
client.on('shardDisconnect', (event, shardId) => {
|
||||
logger.warn('Discord disconnected', { shardId, code: event.code });
|
||||
});
|
||||
client.on('clientReady', () => { retryDelay = 5000; });
|
||||
|
||||
// Login must not block configuration saves while the network is down.
|
||||
// Each attempt has its own client, so stale login completion/cleanup cannot
|
||||
// clear the credentials or stop the connection of its replacement.
|
||||
current.login = client.login(desired.token).catch((err) => {
|
||||
if (version !== revision || runtime !== current) return;
|
||||
logger.error('Discord login failed', err.message);
|
||||
enqueue(async () => {
|
||||
if (version !== revision || runtime !== current) return;
|
||||
await stop();
|
||||
// Credentials require an operator edit; retry network/service failures.
|
||||
if (err.code === 'TokenInvalid' || err.status === 401) return;
|
||||
scheduleRetry(version);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function configure({ enabled, token }) {
|
||||
const next = { enabled: Boolean(enabled), token };
|
||||
if (next.enabled === desired.enabled && next.token === desired.token) return queue;
|
||||
desired = next;
|
||||
const version = ++revision;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
retryDelay = 5000;
|
||||
return enqueue(async () => {
|
||||
if (version !== revision) return;
|
||||
if (desired.enabled) await start(version);
|
||||
else await stop();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
configure,
|
||||
refresh() { runtime?.refresh(); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createConnectionManager };
|
||||
@@ -0,0 +1,146 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createConnectionManager } = require('./connection');
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function setup(t, attempts = []) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const runtimes = [];
|
||||
const logs = [];
|
||||
const manager = createConnectionManager({
|
||||
logger: {
|
||||
warn: (...args) => logs.push(args),
|
||||
error: (...args) => logs.push(args),
|
||||
},
|
||||
createRuntime() {
|
||||
const attempt = attempts[runtimes.length] || {};
|
||||
const client = new EventEmitter();
|
||||
const runtime = {
|
||||
client,
|
||||
stopped: false,
|
||||
destroyed: false,
|
||||
refresh() {},
|
||||
stop() { runtime.stopped = true; },
|
||||
};
|
||||
client.login = async (token) => {
|
||||
runtime.token = token;
|
||||
await attempt.login?.();
|
||||
};
|
||||
client.destroy = async () => {
|
||||
await attempt.destroy?.();
|
||||
runtime.destroyed = true;
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
return { manager, runtimes, logs };
|
||||
}
|
||||
|
||||
test('retries a network failure at startup with a fresh client', async (t) => {
|
||||
const { manager, runtimes } = setup(t, [{ login: () => { throw new Error('getaddrinfo EAI_AGAIN'); } }]);
|
||||
await manager.configure({ enabled: true, token: 'token' });
|
||||
await flush();
|
||||
assert.equal(runtimes[0].stopped, true);
|
||||
assert.equal(runtimes[0].destroyed, true);
|
||||
t.mock.timers.tick(4999);
|
||||
await flush();
|
||||
assert.equal(runtimes.length, 1);
|
||||
t.mock.timers.tick(1);
|
||||
await flush();
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(runtimes[1].token, 'token');
|
||||
assert.notEqual(runtimes[0].client, runtimes[1].client);
|
||||
});
|
||||
|
||||
test('backs off repeated failures and cancels retries when disabled', async (t) => {
|
||||
const failed = { login: () => { throw new Error('Network unavailable'); } };
|
||||
const { manager, runtimes, logs } = setup(t, Array(8).fill(failed));
|
||||
await manager.configure({ enabled: true, token: 'token' });
|
||||
await flush();
|
||||
for (const delay of [5000, 10000, 20000, 40000, 60000]) {
|
||||
t.mock.timers.tick(delay);
|
||||
await flush();
|
||||
}
|
||||
assert.deepEqual(logs.filter(([message]) => message === 'Retrying Discord login').map(([, data]) => data.delayMs),
|
||||
[5000, 10000, 20000, 40000, 60000, 60000]);
|
||||
await manager.configure({ enabled: false, token: 'token' });
|
||||
t.mock.timers.tick(120000);
|
||||
await flush();
|
||||
assert.equal(runtimes.length, 6);
|
||||
});
|
||||
|
||||
test('waits for logout before logging in with a changed token', async (t) => {
|
||||
const logout = deferred();
|
||||
const { manager, runtimes } = setup(t, [{ destroy: () => logout.promise }]);
|
||||
await manager.configure({ enabled: true, token: 'old' });
|
||||
const change = manager.configure({ enabled: true, token: 'new' });
|
||||
await flush();
|
||||
assert.equal(runtimes[0].stopped, true);
|
||||
assert.equal(runtimes.length, 1);
|
||||
logout.resolve();
|
||||
await change;
|
||||
assert.equal(runtimes[0].destroyed, true);
|
||||
assert.equal(runtimes[1].token, 'new');
|
||||
});
|
||||
|
||||
test('retires a pending login before replacement and ignores its stale failure', async (t) => {
|
||||
const login = deferred();
|
||||
const { manager, runtimes } = setup(t, [{ login: () => login.promise }]);
|
||||
await manager.configure({ enabled: true, token: 'old' });
|
||||
const change = manager.configure({ enabled: true, token: 'new' });
|
||||
await flush();
|
||||
assert.equal(runtimes[0].stopped, true);
|
||||
assert.equal(runtimes[0].destroyed, false);
|
||||
login.reject(new Error('Old connection failed'));
|
||||
await change;
|
||||
assert.equal(runtimes[0].destroyed, true);
|
||||
assert.equal(runtimes[1].token, 'new');
|
||||
t.mock.timers.tick(120000);
|
||||
await flush();
|
||||
assert.equal(runtimes.length, 2);
|
||||
});
|
||||
|
||||
test('re-enabling creates a fresh client; unrelated config edits do not reconnect', async (t) => {
|
||||
const { manager, runtimes } = setup(t);
|
||||
await manager.configure({ enabled: true, token: 'token' });
|
||||
await manager.configure({ enabled: true, token: 'token', channels: { general: 'new-channel' } });
|
||||
assert.equal(runtimes.length, 1);
|
||||
await manager.configure({ enabled: false, token: 'token' });
|
||||
assert.equal(runtimes[0].destroyed, true);
|
||||
await manager.configure({ enabled: true, token: 'token' });
|
||||
assert.equal(runtimes.length, 2);
|
||||
});
|
||||
|
||||
test('invalid credentials wait for a token edit instead of retrying', async (t) => {
|
||||
const { manager, runtimes } = setup(t, [{ login: () => {
|
||||
throw Object.assign(new Error('Invalid token'), { code: 'TokenInvalid' });
|
||||
} }]);
|
||||
await manager.configure({ enabled: true, token: 'bad' });
|
||||
await flush();
|
||||
t.mock.timers.tick(120000);
|
||||
await flush();
|
||||
assert.equal(runtimes.length, 1);
|
||||
await manager.configure({ enabled: true, token: 'fixed' });
|
||||
assert.equal(runtimes[1].token, 'fixed');
|
||||
});
|
||||
|
||||
test('client and gateway errors are logged without throwing', async (t) => {
|
||||
const { manager, runtimes, logs } = setup(t);
|
||||
await manager.configure({ enabled: true, token: 'token' });
|
||||
runtimes[0].client.emit('error', new Error('Client failure'));
|
||||
runtimes[0].client.emit('shardError', new Error('Gateway failure'), 0);
|
||||
runtimes[0].client.emit('shardDisconnect', { code: 1006 }, 0);
|
||||
assert.deepEqual(logs.map(([message]) => message), [
|
||||
'Discord client error', 'Discord connection error', 'Discord disconnected',
|
||||
]);
|
||||
});
|
||||
@@ -22,8 +22,8 @@ const { MODES, getMode, setMode } = require('../modeManager');
|
||||
const { sendExternalMessage, sendExternalTyping } = require('../chatService');
|
||||
const { commandReplyToText } = require('../chatService/commandResultFormatter');
|
||||
const { buildReplayVideo, getReplaySources, getDefaultDiscordSources, validateSources, tryTriggerReplay } = require('../replayEngineV2');
|
||||
const { getActiveDrivers } = require('../turnService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const { getActiveDrivers, turnEvents } = require('../turnService');
|
||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
@@ -67,12 +67,14 @@ const {
|
||||
} = require('../privateRoverAccessRequestService');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { createPresenceManager } = require('./presence');
|
||||
const { createConnectionManager } = require('./connection');
|
||||
const { createChannelIO } = require('./channelIO');
|
||||
const { createCommandHandlers } = require('../operatorCommandService');
|
||||
const greenModeService = require('../greenModeService');
|
||||
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
|
||||
const { createIntegrations } = require('./integrations');
|
||||
const { createFleetDailyReports } = require('./fleetDailyReports');
|
||||
const { createLiveStatus } = require('./liveStatus');
|
||||
const fleetReportService = require('../fleetReportService');
|
||||
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
|
||||
const {
|
||||
@@ -86,9 +88,7 @@ const {
|
||||
buildStatusMessage,
|
||||
} = require('../replayDeliveryService/workflow');
|
||||
|
||||
// Discord helper modules retain references to these objects. Mutating those
|
||||
// references on configuration application updates command and integration
|
||||
// behavior without registering a second tree of Discord/event listeners.
|
||||
// Keep configuration object identities stable for the active runtime's helpers.
|
||||
const config = structuredClone(loadConfig());
|
||||
const discordConfig = config.discord || {};
|
||||
let enabled = Boolean(discordConfig.enabled);
|
||||
@@ -106,11 +106,6 @@ const intents = [
|
||||
GatewayIntentBits.MessageContent,
|
||||
];
|
||||
|
||||
const client = new Client({
|
||||
intents,
|
||||
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
||||
});
|
||||
|
||||
function sanitizeMentions(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
@@ -156,300 +151,322 @@ function countReady() {
|
||||
return { ready, total };
|
||||
}
|
||||
|
||||
const channelIO = createChannelIO({
|
||||
client,
|
||||
logger,
|
||||
sanitizeMentions,
|
||||
});
|
||||
// Each connection owns its listeners, timers, and cached Discord objects.
|
||||
// Rebuilding them prevents a reconnect from retaining a destroyed client.
|
||||
function createDiscordRuntime() {
|
||||
const client = new Client({
|
||||
intents,
|
||||
partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
|
||||
});
|
||||
const channelIO = createChannelIO({
|
||||
client,
|
||||
logger,
|
||||
sanitizeMentions,
|
||||
});
|
||||
|
||||
const presence = createPresenceManager({
|
||||
client,
|
||||
logger,
|
||||
getMode,
|
||||
getGlobalObjective,
|
||||
countReady,
|
||||
});
|
||||
const presence = createPresenceManager({
|
||||
client,
|
||||
logger,
|
||||
getMode,
|
||||
getGlobalObjective,
|
||||
countReady,
|
||||
});
|
||||
|
||||
const replayCaption = createReplayCaptionBuilder({
|
||||
io,
|
||||
rovers,
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
sanitizeMentions,
|
||||
});
|
||||
const replayCaption = createReplayCaptionBuilder({
|
||||
io,
|
||||
rovers,
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
sanitizeMentions,
|
||||
});
|
||||
|
||||
// Discord is the preferred replay host only while this optional feature is
|
||||
// active. The core replay delivery service owns generation and automatically
|
||||
// falls back to its local media store when any operation below fails.
|
||||
registerPreferredDeliveryProvider({
|
||||
async begin(job) {
|
||||
const channelId = discordConfig.channels?.replay;
|
||||
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
|
||||
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
|
||||
const channel = await channelIO.fetchChannel(channelId);
|
||||
return {
|
||||
channelId,
|
||||
progressMessage,
|
||||
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
|
||||
};
|
||||
},
|
||||
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
|
||||
const progressMessage = context?.progressMessage;
|
||||
try {
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
// Discord is the preferred replay host only while this optional feature is
|
||||
// active. The core replay delivery service owns generation and automatically
|
||||
// falls back to its local media store when any operation below fails.
|
||||
const unregisterReplayProvider = registerPreferredDeliveryProvider({
|
||||
async begin(job) {
|
||||
const channelId = discordConfig.channels?.replay;
|
||||
if (!enabled || !channelId) throw new Error('Discord replay delivery is disabled');
|
||||
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
|
||||
const channel = await channelIO.fetchChannel(channelId);
|
||||
return {
|
||||
channelId,
|
||||
progressMessage,
|
||||
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
|
||||
};
|
||||
},
|
||||
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
|
||||
const progressMessage = context?.progressMessage;
|
||||
try {
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
}
|
||||
// Every delivery path uses the job creation time, so a Discord upload
|
||||
// and a server-hosted fallback always expose the same replay filename.
|
||||
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
|
||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
||||
if (progressMessage?.edit) {
|
||||
// The attachment URL is already durable once Discord returns it. A
|
||||
// cosmetic progress-edit failure must not trigger a duplicate local
|
||||
// replay or replace the successful media payload sent to clients.
|
||||
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
|
||||
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
|
||||
});
|
||||
}
|
||||
return media;
|
||||
} catch (err) {
|
||||
err.progressMessage = progressMessage;
|
||||
throw err;
|
||||
} finally {
|
||||
if (context?.stopTyping) context.stopTyping();
|
||||
}
|
||||
// Every delivery path uses the job creation time, so a Discord upload
|
||||
// and a server-hosted fallback always expose the same replay filename.
|
||||
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
|
||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
||||
if (progressMessage?.edit) {
|
||||
// The attachment URL is already durable once Discord returns it. A
|
||||
// cosmetic progress-edit failure must not trigger a duplicate local
|
||||
// replay or replace the successful media payload sent to clients.
|
||||
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
|
||||
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
|
||||
},
|
||||
async completeFallback({ context, media }) {
|
||||
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
|
||||
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
|
||||
if (context?.progressMessage?.reply) {
|
||||
await context.progressMessage.reply({
|
||||
content: `Replay hosted by the rover server: ${publicUrl}`,
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
}
|
||||
return media;
|
||||
} catch (err) {
|
||||
err.progressMessage = progressMessage;
|
||||
throw err;
|
||||
} finally {
|
||||
if (context?.stopTyping) context.stopTyping();
|
||||
}
|
||||
},
|
||||
async completeFallback({ context, media }) {
|
||||
const publicBaseUrl = String(config.publicUrl || '').replace(/\/$/, '');
|
||||
const publicUrl = publicBaseUrl ? `${publicBaseUrl}${media.url}` : media.url;
|
||||
if (context?.progressMessage?.reply) {
|
||||
await context.progressMessage.reply({
|
||||
content: `Replay hosted by the rover server: ${publicUrl}`,
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const commandDependencies = {
|
||||
logger,
|
||||
client,
|
||||
io,
|
||||
rovers,
|
||||
roverManager,
|
||||
getMode,
|
||||
MODES,
|
||||
setMode,
|
||||
lockRover,
|
||||
getNickname,
|
||||
getActiveDrivers,
|
||||
buildReplayVideo,
|
||||
getReplaySources,
|
||||
getDefaultDiscordSources,
|
||||
validateSources,
|
||||
tryTriggerReplay,
|
||||
getGlobalObjective,
|
||||
setGlobalObjective,
|
||||
clearGlobalObjective,
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
// Room-light lock commands must use the same Home Assistant service instance
|
||||
// as sockets, HA button triggers, and idle/darkness policies. Passing the
|
||||
// service into the shared command router keeps Discord and mirrored web-chat
|
||||
// command behavior aligned without duplicating Home Assistant calls here.
|
||||
homeAssistantService,
|
||||
homeAssistantActivitiesService,
|
||||
greenModeService,
|
||||
liftService,
|
||||
neatoService,
|
||||
isFeatureEnabled,
|
||||
getGuildConfig,
|
||||
setGuildConfig,
|
||||
removeGuildConfig,
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
deterUser,
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listUsersForAdmin,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
setUserPermission,
|
||||
sanitizeMentions,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
discordConfig,
|
||||
config,
|
||||
};
|
||||
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
|
||||
const commands = createCommandHandlers(commandDependencies);
|
||||
const commandDependencies = {
|
||||
logger,
|
||||
client,
|
||||
io,
|
||||
rovers,
|
||||
roverManager,
|
||||
getMode,
|
||||
MODES,
|
||||
setMode,
|
||||
lockRover,
|
||||
getNickname,
|
||||
getActiveDrivers,
|
||||
buildReplayVideo,
|
||||
getReplaySources,
|
||||
getDefaultDiscordSources,
|
||||
validateSources,
|
||||
tryTriggerReplay,
|
||||
getGlobalObjective,
|
||||
setGlobalObjective,
|
||||
clearGlobalObjective,
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
// Room-light lock commands must use the same Home Assistant service instance
|
||||
// as sockets, HA button triggers, and idle/darkness policies. Passing the
|
||||
// service into the shared command router keeps Discord and mirrored web-chat
|
||||
// command behavior aligned without duplicating Home Assistant calls here.
|
||||
homeAssistantService,
|
||||
homeAssistantActivitiesService,
|
||||
greenModeService,
|
||||
liftService,
|
||||
neatoService,
|
||||
isFeatureEnabled,
|
||||
getGuildConfig,
|
||||
setGuildConfig,
|
||||
removeGuildConfig,
|
||||
normalizeMode,
|
||||
VALID_MODES,
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
deterUser,
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listUsersForAdmin,
|
||||
listUsersWithPermission,
|
||||
listRegisteredPermissions,
|
||||
setUserPermission,
|
||||
sanitizeMentions,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
discordConfig,
|
||||
config,
|
||||
};
|
||||
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
|
||||
const commands = createCommandHandlers(commandDependencies);
|
||||
|
||||
const integrations = createIntegrations({
|
||||
logger,
|
||||
client,
|
||||
config,
|
||||
discordConfig,
|
||||
rovers,
|
||||
roverManager,
|
||||
getMode,
|
||||
MODES,
|
||||
getGlobalObjective,
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
subscribe,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
denyRequest,
|
||||
attachPrivateAccessDmMessage,
|
||||
getPrivateAccessRequestByMessageId,
|
||||
approvePrivateAccessRequest,
|
||||
denyPrivateAccessRequest,
|
||||
getLockdownAdminIds,
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
fetchChannel: channelIO.fetchChannel,
|
||||
clearTypingMessage: channelIO.clearTypingMessage,
|
||||
sendTypingMessage: channelIO.sendTypingMessage,
|
||||
schedulePresenceRotation: presence.schedulePresenceRotation,
|
||||
buildReplayVideo,
|
||||
sanitizeMentions,
|
||||
});
|
||||
|
||||
const integrationHandlers = integrations.register();
|
||||
|
||||
function isTextCommand(content) {
|
||||
// Both transports share this parser so command detection cannot drift from
|
||||
// the dispatcher when an installation changes its prefix.
|
||||
return parseCommandText(content, config).matched;
|
||||
}
|
||||
|
||||
function isBridgeChannelMessage(message) {
|
||||
if (!message?.guild?.id || !message?.channelId) return false;
|
||||
const guildConfig = getGuildConfig(message.guild.id);
|
||||
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
|
||||
}
|
||||
|
||||
function createBridgeMirroredCommandMessage(message) {
|
||||
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
|
||||
const originalReply = message.reply.bind(message);
|
||||
return Object.assign(Object.create(message), {
|
||||
reply: async (payload) => {
|
||||
const sent = await originalReply(payload);
|
||||
const text = sanitizeMentions(commandReplyToText(payload));
|
||||
if (text) {
|
||||
// Discord command replies are mirrored to web chat by the Discord
|
||||
// command adapter, not by the chat bridge. The bridge continues to
|
||||
// ignore bot-authored Discord messages, which prevents typing helper
|
||||
// messages and bot replies from feeding back into chat.
|
||||
sendExternalMessage({
|
||||
text,
|
||||
nickname: client.user?.username || 'Rover bot',
|
||||
role: 'admin',
|
||||
roverId: null,
|
||||
discordGuildId: message.guild.id,
|
||||
discordGuildName: message.guild.name,
|
||||
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
|
||||
discordChannelId: message.channelId,
|
||||
discordUserId: client.user?.id || null,
|
||||
discordUserName: client.user?.username || 'Rover bot',
|
||||
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
|
||||
bot: true,
|
||||
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
|
||||
});
|
||||
}
|
||||
return sent;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
try {
|
||||
await integrationHandlers.handleBridgeInbound(message);
|
||||
const commandMessage = createBridgeMirroredCommandMessage(message);
|
||||
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
|
||||
} catch (err) {
|
||||
logger.warn('Error handling Discord message', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
let fleetDailyReports = null;
|
||||
|
||||
function restartFleetDailyReports() {
|
||||
fleetDailyReports?.stop();
|
||||
fleetDailyReports = createFleetDailyReports({
|
||||
logger,
|
||||
client,
|
||||
config,
|
||||
discordConfig,
|
||||
fleetConfig: config.fleetReports || {},
|
||||
fleetReportService,
|
||||
rovers,
|
||||
roverManager,
|
||||
getMode,
|
||||
MODES,
|
||||
getGlobalObjective,
|
||||
getActiveDrivers,
|
||||
getNickname,
|
||||
subscribe,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
attachDmMessage,
|
||||
getRequestByMessageId,
|
||||
approveRequest,
|
||||
denyRequest,
|
||||
attachPrivateAccessDmMessage,
|
||||
getPrivateAccessRequestByMessageId,
|
||||
approvePrivateAccessRequest,
|
||||
denyPrivateAccessRequest,
|
||||
getLockdownAdminIds,
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
fetchChannel: channelIO.fetchChannel,
|
||||
clearTypingMessage: channelIO.clearTypingMessage,
|
||||
sendTypingMessage: channelIO.sendTypingMessage,
|
||||
schedulePresenceRotation: presence.schedulePresenceRotation,
|
||||
buildReplayVideo,
|
||||
sanitizeMentions,
|
||||
});
|
||||
fleetDailyReports.start();
|
||||
|
||||
const integrationHandlers = integrations.register();
|
||||
const liveStatus = createLiveStatus({
|
||||
client, logger, discordConfig, roverManager, getActiveDrivers, getNickname,
|
||||
io, fetchChannel: channelIO.fetchChannel, sanitizeMentions,
|
||||
turnEvents, nicknameEvents,
|
||||
});
|
||||
|
||||
function isTextCommand(content) {
|
||||
// Both transports share this parser so command detection cannot drift from
|
||||
// the dispatcher when an installation changes its prefix.
|
||||
return parseCommandText(content, config).matched;
|
||||
}
|
||||
|
||||
function isBridgeChannelMessage(message) {
|
||||
if (!message?.guild?.id || !message?.channelId) return false;
|
||||
const guildConfig = getGuildConfig(message.guild.id);
|
||||
return Boolean(guildConfig?.channelId && String(message.channelId) === String(guildConfig.channelId));
|
||||
}
|
||||
|
||||
function createBridgeMirroredCommandMessage(message) {
|
||||
if (!isBridgeChannelMessage(message) || !isTextCommand(message.content)) return message;
|
||||
const originalReply = message.reply.bind(message);
|
||||
return Object.assign(Object.create(message), {
|
||||
reply: async (payload) => {
|
||||
const sent = await originalReply(payload);
|
||||
const text = sanitizeMentions(commandReplyToText(payload));
|
||||
if (text) {
|
||||
// Discord command replies are mirrored to web chat by the Discord
|
||||
// command adapter, not by the chat bridge. The bridge continues to
|
||||
// ignore bot-authored Discord messages, which prevents typing helper
|
||||
// messages and bot replies from feeding back into chat.
|
||||
sendExternalMessage({
|
||||
text,
|
||||
nickname: client.user?.username || 'Rover bot',
|
||||
role: 'admin',
|
||||
roverId: null,
|
||||
discordGuildId: message.guild.id,
|
||||
discordGuildName: message.guild.name,
|
||||
discordGuildIconUrl: message.guild.iconURL?.({ extension: 'png', size: 64 }) || null,
|
||||
discordChannelId: message.channelId,
|
||||
discordUserId: client.user?.id || null,
|
||||
discordUserName: client.user?.username || 'Rover bot',
|
||||
discordUserAvatarUrl: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
|
||||
bot: true,
|
||||
profileImage: client.user?.displayAvatarURL?.({ extension: 'png', size: 64 }) || null,
|
||||
});
|
||||
}
|
||||
return sent;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
try {
|
||||
if (message.channelId === discordConfig.channels?.liveStatus?.trim()) {
|
||||
liveStatus.update(true);
|
||||
return;
|
||||
}
|
||||
await integrationHandlers.handleBridgeInbound(message);
|
||||
const commandMessage = createBridgeMirroredCommandMessage(message);
|
||||
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
|
||||
} catch (err) {
|
||||
logger.warn('Error handling Discord message', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
let fleetDailyReports = null;
|
||||
|
||||
function restartFleetDailyReports() {
|
||||
fleetDailyReports?.stop();
|
||||
fleetDailyReports = createFleetDailyReports({
|
||||
logger,
|
||||
discordConfig,
|
||||
fleetConfig: config.fleetReports || {},
|
||||
fleetReportService,
|
||||
roverManager,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
});
|
||||
fleetDailyReports.start();
|
||||
}
|
||||
|
||||
client.on('clientReady', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
presence.schedulePresenceRotation();
|
||||
// Discord is only a delivery consumer. Starting its scheduler after the bot
|
||||
// is ready avoids failed sends during login while the collector continues to
|
||||
// operate independently of Discord availability.
|
||||
restartFleetDailyReports();
|
||||
liveStatus.start();
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
refresh() {
|
||||
if (client.isReady()) restartFleetDailyReports();
|
||||
if (client.isReady()) liveStatus.start();
|
||||
},
|
||||
stop() {
|
||||
for (const event of ['clientReady', 'messageCreate', 'typingStart', 'messageReactionAdd']) {
|
||||
client.removeAllListeners(event);
|
||||
}
|
||||
integrationHandlers.stop();
|
||||
unregisterReplayProvider();
|
||||
presence.stop();
|
||||
liveStatus.stop();
|
||||
channelIO.stop();
|
||||
fleetDailyReports?.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
client.on('ready', () => {
|
||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||
presence.schedulePresenceRotation();
|
||||
// Discord is only a delivery consumer. Starting its scheduler after the bot
|
||||
// is ready avoids failed sends during login while the collector continues to
|
||||
// operate independently of Discord availability.
|
||||
restartFleetDailyReports();
|
||||
});
|
||||
const connection = createConnectionManager({ createRuntime: createDiscordRuntime, logger });
|
||||
|
||||
function replaceObject(target, source = {}) {
|
||||
Object.keys(target).forEach((key) => delete target[key]);
|
||||
Object.assign(target, structuredClone(source));
|
||||
}
|
||||
|
||||
function applyDiscordConfig(nextDiscordConfig = {}) {
|
||||
const wasEnabled = enabled;
|
||||
const previousToken = discordConfig.token;
|
||||
async function applyDiscordConfig(nextDiscordConfig = {}) {
|
||||
replaceObject(discordConfig, nextDiscordConfig);
|
||||
config.discord = discordConfig;
|
||||
enabled = Boolean(discordConfig.enabled);
|
||||
|
||||
if (!enabled) {
|
||||
fleetDailyReports?.stop();
|
||||
fleetDailyReports = null;
|
||||
if (wasEnabled) client.destroy();
|
||||
return;
|
||||
}
|
||||
if (!wasEnabled || previousToken !== discordConfig.token) {
|
||||
if (wasEnabled) client.destroy();
|
||||
// Login health is reported by Discord itself; do not hold the committed
|
||||
// configuration request open while an external network service connects.
|
||||
client.login(discordConfig.token).catch((err) => {
|
||||
logger.error('Discord login failed after configuration change', err.message);
|
||||
});
|
||||
} else if (client.isReady()) {
|
||||
restartFleetDailyReports();
|
||||
}
|
||||
await connection.configure(discordConfig);
|
||||
connection.refresh();
|
||||
}
|
||||
|
||||
function applySharedConfigSection(section, value) {
|
||||
config[section] = structuredClone(value);
|
||||
// Fleet delivery owns a timer derived from both Discord and fleet settings.
|
||||
// Reconnecting is unnecessary; rebuild only that scheduler when ready.
|
||||
if (section === 'fleetReports' && client.isReady()) {
|
||||
restartFleetDailyReports();
|
||||
if (section === 'fleetReports') {
|
||||
connection.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,10 +475,8 @@ registerConfigurationHandler('discord', applyDiscordConfig);
|
||||
registerConfigurationHandler(section, (value) => applySharedConfigSection(section, value));
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
client.login(discordConfig.token).catch((err) => {
|
||||
logger.error('Discord login failed', err.message);
|
||||
});
|
||||
}
|
||||
connection.configure(discordConfig).catch((err) => {
|
||||
logger.error('Discord startup failed', err.message);
|
||||
});
|
||||
|
||||
module.exports = {};
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function createIntegrations(deps) {
|
||||
const userAnnouncements = createUserAnnouncements({ ...deps, sendToChannel, schedulePresenceRotation });
|
||||
|
||||
function register() {
|
||||
let stopped = false;
|
||||
client.on('typingStart', (typing) => {
|
||||
chat.handleDiscordTypingStart(typing).catch((err) => logger.warn('Error handling Discord typing', err.message));
|
||||
});
|
||||
@@ -33,14 +34,31 @@ function createIntegrations(deps) {
|
||||
dm.handlePrivateAccessReaction(reaction, user).catch((err) => logger.warn('Error handling private access reaction', err.message));
|
||||
});
|
||||
|
||||
subscribe('*', handleBusEvent);
|
||||
subscribe('*', userAnnouncements.handleBusEvent);
|
||||
subscribe('verification.requested', dm.sendVerificationRequestDms);
|
||||
subscribe('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
|
||||
subscribe('chat:message', chat.handleChatBridgeOutbound);
|
||||
subscribe('chat:typing', chat.handleChatTypingOutbound);
|
||||
// Only the active, ready connection consumes server events. Retain each
|
||||
// unsubscribe function so replacement clients never duplicate deliveries.
|
||||
const unsubscribe = [];
|
||||
function subscribeWhileReady(type, handler) {
|
||||
unsubscribe.push(subscribe(type, (event) => {
|
||||
Promise.resolve().then(() => {
|
||||
if (!stopped && client.isReady()) return handler(event);
|
||||
}).catch((err) => {
|
||||
logger.warn('Error handling Discord integration event', { type, error: err.message });
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
subscribeWhileReady('*', handleBusEvent);
|
||||
subscribeWhileReady('*', userAnnouncements.handleBusEvent);
|
||||
subscribeWhileReady('verification.requested', dm.sendVerificationRequestDms);
|
||||
subscribeWhileReady('privateRoverAccess.requested', dm.sendPrivateRoverAccessRequestDms);
|
||||
subscribeWhileReady('chat:message', chat.handleChatBridgeOutbound);
|
||||
subscribeWhileReady('chat:typing', chat.handleChatTypingOutbound);
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
unsubscribe.forEach((remove) => remove());
|
||||
},
|
||||
handleBridgeInbound: chat.handleBridgeInbound,
|
||||
handleBusEvent,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Owns the single-message live report and the dedicated channel's cleanup.
|
||||
const { PermissionFlagsBits } = require('discord.js');
|
||||
const { buildRoverStatusSnapshot } = require('./batteryEmbeds');
|
||||
|
||||
function createLiveStatus({ client, logger, discordConfig, roverManager, getActiveDrivers,
|
||||
getNickname, io, fetchChannel, sanitizeMentions,
|
||||
turnEvents, nicknameEvents }) {
|
||||
const COOLDOWN_MS = 2000;
|
||||
let timer = null;
|
||||
let sweepTimer = null;
|
||||
let pending = false;
|
||||
let cleanupNeeded = false;
|
||||
let nextUpdateAt = 0;
|
||||
let listening = false;
|
||||
const unsubscribe = [];
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let channelId = null;
|
||||
let messageId = null;
|
||||
let lastContent = null;
|
||||
|
||||
const clean = (value, limit = 120) => sanitizeMentions(String(value ?? ''))
|
||||
.replace(/[\r\n]+/g, ' ').slice(0, limit);
|
||||
|
||||
function buildReport() {
|
||||
const drivers = getActiveDrivers();
|
||||
const roster = roverManager.getRoster();
|
||||
const lines = [];
|
||||
for (const rover of roster) {
|
||||
const snapshot = buildRoverStatusSnapshot(roverManager.rovers.get(rover.id));
|
||||
const driverId = drivers[rover.id];
|
||||
const driver = driverId ? clean(getNickname(io.sockets.sockets.get(driverId)) || 'Someone', 32) : null;
|
||||
const status = snapshot?.docked ? 'docked' : rover.needsHelp ? 'needs help'
|
||||
: driver ? `${driver} driving` : rover.locked ? 'locked' : 'available';
|
||||
lines.push(`${clean(rover.name, 60)}: ${status}`);
|
||||
}
|
||||
let content = lines.join('\n') || 'No rovers online.';
|
||||
if (content.length > 2000) content = `${content.slice(0, 1950)}\n… More rovers online.`;
|
||||
return { content, allowedMentions: { parse: [] } };
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (stopped || running || timer || !pending || !client.isReady()
|
||||
|| !discordConfig.channels?.liveStatus?.trim()) return;
|
||||
// Use a fixed deadline rather than resetting a debounce on every sensor
|
||||
// frame: a continuously changing rover must not postpone updates forever.
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
flush();
|
||||
}, Math.max(0, nextUpdateAt - Date.now()));
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function update(forceCleanup = false) {
|
||||
if (stopped) return;
|
||||
pending = true;
|
||||
cleanupNeeded ||= forceCleanup;
|
||||
schedule();
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (stopped || running || !client.isReady()) return;
|
||||
pending = false;
|
||||
const target = discordConfig.channels?.liveStatus?.trim();
|
||||
if (target !== channelId) {
|
||||
channelId = target;
|
||||
messageId = null;
|
||||
lastContent = null;
|
||||
}
|
||||
if (!target) return;
|
||||
running = true;
|
||||
const cleanup = cleanupNeeded;
|
||||
cleanupNeeded = false;
|
||||
let usedDiscord = false;
|
||||
const active = () => !stopped && client.isReady()
|
||||
&& discordConfig.channels?.liveStatus?.trim() === target;
|
||||
try {
|
||||
const report = buildReport();
|
||||
const replace = report.content !== lastContent;
|
||||
// Sensor events can arrive many times per second. Compare the rendered
|
||||
// report locally before consuming any Discord API capacity.
|
||||
if (!replace && !cleanup) return;
|
||||
usedDiscord = true;
|
||||
const channel = await fetchChannel(target);
|
||||
if (!active()) return;
|
||||
if (!channel?.isTextBased() || !channel.guild || !channel.messages || !channel.send) {
|
||||
throw new Error('Live status requires a guild text channel');
|
||||
}
|
||||
const permissions = channel.permissionsFor(client.user);
|
||||
if (!permissions?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory,
|
||||
PermissionFlagsBits.SendMessages, PermissionFlagsBits.ManageMessages])) {
|
||||
throw new Error('Live status requires View Channel, Read Message History, Send Messages, and Manage Messages');
|
||||
}
|
||||
let found = false;
|
||||
let before;
|
||||
// Paginate the entire history, including pinned and old messages. Individual
|
||||
// deletion also handles messages too old for Discord's bulk-delete endpoint.
|
||||
while (active()) {
|
||||
const messages = await channel.messages.fetch({ limit: 100, before, cache: false });
|
||||
if (!active()) return;
|
||||
for (const message of messages.values()) {
|
||||
if (!active()) return;
|
||||
if (!replace && message.id === messageId) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await message.delete();
|
||||
} catch (err) {
|
||||
if (err.code !== 10008) throw err; // Already-deleted messages need no cleanup.
|
||||
}
|
||||
}
|
||||
if (messages.size < 100) break;
|
||||
before = messages.last().id;
|
||||
}
|
||||
if (!active()) return;
|
||||
if (replace || !found) {
|
||||
const message = await channel.send(report);
|
||||
if (!active()) return;
|
||||
messageId = message.id;
|
||||
lastContent = report.content;
|
||||
}
|
||||
} catch (err) {
|
||||
cleanupNeeded = true;
|
||||
logger.warn('Discord live status update failed', { channelId: target, error: err.message });
|
||||
} finally {
|
||||
running = false;
|
||||
// Cool down after completion, so slow deletions and rate-limited requests
|
||||
// cannot overlap with another batch. Events received meanwhile stay pending.
|
||||
if (usedDiscord) nextUpdateAt = Date.now() + COOLDOWN_MS;
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
|
||||
const onChange = () => update();
|
||||
function onDelete(message) {
|
||||
if (message.channelId === discordConfig.channels?.liveStatus?.trim()
|
||||
&& message.id === messageId) update(true);
|
||||
}
|
||||
function onBulkDelete(messages) {
|
||||
for (const message of messages.values()) onDelete(message);
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
start() {
|
||||
if (stopped) return;
|
||||
if (!listening) {
|
||||
listening = true;
|
||||
for (const event of ['rover', 'sensor', 'lock', 'private', 'help']) {
|
||||
roverManager.managerEvents.on(event, onChange);
|
||||
unsubscribe.push(() => roverManager.managerEvents.off(event, onChange));
|
||||
}
|
||||
turnEvents.on('activeDriver', onChange);
|
||||
nicknameEvents.on('change', onChange);
|
||||
client.on('messageDelete', onDelete);
|
||||
client.on('messageDeleteBulk', onBulkDelete);
|
||||
unsubscribe.push(
|
||||
() => turnEvents.off('activeDriver', onChange),
|
||||
() => nicknameEvents.off('change', onChange),
|
||||
() => client.off('messageDelete', onDelete),
|
||||
() => client.off('messageDeleteBulk', onBulkDelete),
|
||||
);
|
||||
}
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
if (discordConfig.channels?.liveStatus?.trim()) {
|
||||
// Recover from missed gateway events and failed operations without polling
|
||||
// for normal rover changes, which are handled by the subscriptions above.
|
||||
sweepTimer = setInterval(() => update(true), 30000);
|
||||
sweepTimer.unref?.();
|
||||
}
|
||||
update(true);
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(sweepTimer);
|
||||
unsubscribe.forEach((remove) => remove());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLiveStatus };
|
||||
@@ -7,6 +7,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
|
||||
const PRESENCE_ROTATE_MS = 20000;
|
||||
let presenceInterval = null;
|
||||
let presenceShowObjective = false;
|
||||
let stopped = false;
|
||||
|
||||
function truncatePresenceText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
@@ -40,6 +41,7 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
|
||||
}
|
||||
|
||||
function schedulePresenceRotation() {
|
||||
if (stopped) return;
|
||||
if (presenceInterval) {
|
||||
clearInterval(presenceInterval);
|
||||
presenceInterval = null;
|
||||
@@ -59,6 +61,11 @@ function createPresenceManager({ client, logger, getMode, getGlobalObjective, co
|
||||
}
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearInterval(presenceInterval);
|
||||
presenceInterval = null;
|
||||
},
|
||||
schedulePresenceRotation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
|
||||
// by the service, so queue transitions cannot create physical rover ownership.
|
||||
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
|
||||
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
|
||||
let timer = null;
|
||||
|
||||
function remove(socketId) {
|
||||
state.queue = state.queue.filter((id) => id !== socketId);
|
||||
}
|
||||
|
||||
function stopOperator(reason) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
const previous = state.operatorSocketId;
|
||||
state.operatorSocketId = null;
|
||||
state.deadline = null;
|
||||
state.generation += 1;
|
||||
if (!previous) return;
|
||||
revokeVideo(previous);
|
||||
stopMotion(reason).catch(() => {});
|
||||
events.emit('operator', { socketId: previous, action: 'release', reason });
|
||||
}
|
||||
|
||||
function scheduleTurn() {
|
||||
// Uncontested control has no deadline. Starting a new wait gets a full turn,
|
||||
// while later queue changes must not reset an already running countdown.
|
||||
if (!state.operatorSocketId || !state.queue.length) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
state.deadline = null;
|
||||
return;
|
||||
}
|
||||
if (timer) return;
|
||||
const duration = getTurnDurationMs();
|
||||
state.deadline = Date.now() + duration;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
state.queue = state.queue.filter(eligible);
|
||||
if (state.queue.length) {
|
||||
// Preserve PTZ's explicit request-to-rejoin rule after a handoff.
|
||||
stopOperator('turn-expired');
|
||||
advance('turn-expired');
|
||||
} else {
|
||||
scheduleTurn();
|
||||
emitChange('turn-uncontested');
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function eligible(socketId) {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
return socket && !socket.disconnected && canParticipate(socket);
|
||||
}
|
||||
|
||||
function advance(reason) {
|
||||
state.queue = state.queue.filter(eligible);
|
||||
while (!state.operatorSocketId && state.queue.length) {
|
||||
const next = state.queue.shift();
|
||||
if (!eligible(next)) continue;
|
||||
state.operatorSocketId = next;
|
||||
state.generation += 1;
|
||||
events.emit('operator', { socketId: next, action: 'active' });
|
||||
}
|
||||
scheduleTurn();
|
||||
emitChange(reason);
|
||||
}
|
||||
|
||||
function claim(socket) {
|
||||
if (!eligible(socket.id)) throw new Error('PTZ participation required');
|
||||
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
|
||||
state.queue.push(socket.id);
|
||||
advance('queue-join');
|
||||
}
|
||||
|
||||
function release(socketId, reason = 'turn-release') {
|
||||
remove(socketId);
|
||||
if (state.operatorSocketId === socketId) stopOperator(reason);
|
||||
advance(reason);
|
||||
}
|
||||
|
||||
function reconcile() {
|
||||
state.queue = state.queue.filter(eligible);
|
||||
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
|
||||
advance('access-change');
|
||||
}
|
||||
|
||||
function clear(reason) {
|
||||
state.queue = [];
|
||||
stopOperator(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
state, claim, release, reconcile, clear,
|
||||
revoke: (reason) => { stopOperator(reason); advance(reason); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createParticipation };
|
||||
@@ -0,0 +1,123 @@
|
||||
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
|
||||
function registerSocketGateway(deps) {
|
||||
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
|
||||
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
|
||||
listPresets, gotoPreset, createPreset, removePreset, passesMode,
|
||||
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
|
||||
getSnapshot, removeSnapshotSubscriptions } = deps;
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
|
||||
participation.revoke('admin-revoke');
|
||||
cb({ ok: true });
|
||||
});
|
||||
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
|
||||
catch (err) { cb({ error: err.message }); }
|
||||
});
|
||||
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
/*
|
||||
Acknowledge acceptance of the newest desired state immediately. The
|
||||
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||
request latency so browser heartbeats cannot accumulate while waiting
|
||||
for a camera SOAP response.
|
||||
*/
|
||||
cb(acceptMotionIntent(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, status: await getStatus(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, light: await setSpotlight(socket, payload) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, ir: await setIr(socket, payload) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb({ ok: true, presets: await listPresets(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await gotoPreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await createPreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await removePreset(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
|
||||
const ids = normalizeSnapshotIds(payload);
|
||||
addSnapshotSubscription(socket, ids);
|
||||
const lastSnapshotState = getSnapshot();
|
||||
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
|
||||
cb({ ok: true, subscribed: ids });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
|
||||
const { payload } = normalizeSocketArgs(firstArg, secondArg);
|
||||
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
participation.release(socket.id, 'disconnect');
|
||||
removeSnapshotSubscriptions(socket.id);
|
||||
emitChange('disconnect');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerSocketGateway };
|
||||
@@ -0,0 +1,9 @@
|
||||
const labels = {
|
||||
...require('../turnService/labels'),
|
||||
waiting: 'Someone else has control',
|
||||
active: 'You control the camera',
|
||||
showName: 'Show camera name',
|
||||
hideName: 'Hide camera name',
|
||||
};
|
||||
|
||||
module.exports = labels;
|
||||
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
|
||||
replay`, follows the camera currently controlled by that socket just as it
|
||||
follows an assigned rover below.
|
||||
|
||||
isOperator is deliberately stricter than PTZ access or queue membership:
|
||||
turn.ownsControl is deliberately stricter than PTZ access or queue membership:
|
||||
spectators and users waiting for a camera turn must not silently replay a
|
||||
camera they are not currently operating. Keeping this rule here also makes
|
||||
every web replay entry point share the same default instead of teaching the
|
||||
chat-command adapter about PTZ-specific state.
|
||||
*/
|
||||
if (ptzCameraService.getPublicState(socket).isOperator) {
|
||||
if (ptzCameraService.getPublicState(socket).turn.ownsControl) {
|
||||
const source = ptzCameraService.getReplaySource();
|
||||
if (!source) return [];
|
||||
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { getOperatingMode } = require('../operatingModeService');
|
||||
// rover Manager private access
|
||||
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
|
||||
@@ -80,6 +81,7 @@ function createPrivateAccessPolicy(deps) {
|
||||
allowClosedPrivateGrantInLockdown = false,
|
||||
allowClosedPrivateCurrentDriver = false,
|
||||
} = options;
|
||||
if (getOperatingMode(socket) !== 'rover') return 'Leave PTZ before requesting rover control';
|
||||
if (!record) return 'Unknown rover';
|
||||
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
|
||||
if (record.locked && !isAdmin(socket)) return 'Rover locked';
|
||||
|
||||
@@ -5,16 +5,18 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('sessionService');
|
||||
const { getFeatureFlags, configurationEvents } = require('../../configuration');
|
||||
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
||||
const { getOperatingMode, setOperatingMode, operatingModeEvents } = require('../operatingModeService');
|
||||
const { getMode, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { managerEvents } = roverManager;
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
|
||||
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const {
|
||||
getPublicState: getPtzCameraState,
|
||||
getChatTargetForSocket: getPtzChatTargetForSocket,
|
||||
PTZ_CAMERA_ID,
|
||||
getOperatingModeDisplay,
|
||||
getParticipantSocketIds,
|
||||
ptzCameraEvents,
|
||||
} = require('../ptzCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||
@@ -111,18 +113,13 @@ function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
|
||||
|
||||
function countControllableUsers(userEntries = []) {
|
||||
const ids = new Set();
|
||||
const ptzParticipants = new Set(getParticipantSocketIds());
|
||||
userEntries.forEach((entry) => {
|
||||
const role = String(entry?.role || '');
|
||||
if (role === 'spectator') return;
|
||||
const socketId = String(entry?.socketId || '').trim();
|
||||
const roverId = String(entry?.roverId || '').trim();
|
||||
/*
|
||||
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
|
||||
id and normal drivers to their physical rover. Counting entries after that
|
||||
normalization gives the browser the same conceptual "controllable users"
|
||||
count it shows in the user/queue panels without duplicating PTZ UI logic.
|
||||
*/
|
||||
if (socketId && roverId) ids.add(socketId);
|
||||
if (socketId && (roverId || ptzParticipants.has(socketId))) ids.add(socketId);
|
||||
});
|
||||
return ids.size;
|
||||
}
|
||||
@@ -146,19 +143,14 @@ function buildUserEntry(socket) {
|
||||
&& roverManager.isDriver(assignment.roverId, socket)
|
||||
? assignment.roverId
|
||||
: null;
|
||||
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
|
||||
return {
|
||||
socketId: socket.id,
|
||||
userId: socket?.data?.userId || null,
|
||||
nickname: getNickname(socket) || null,
|
||||
role,
|
||||
/*
|
||||
PTZ is not inserted into the physical rover roster, but for chat and user
|
||||
presence it should read like the user moved to a rover-like target. Prefer
|
||||
the PTZ chat target while the socket is queued or operating so presence,
|
||||
queue lookup, and chat identity all agree.
|
||||
*/
|
||||
roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
|
||||
operatingMode: getOperatingMode(socket),
|
||||
operatingModeDisplay: getOperatingModeDisplay(socket.id),
|
||||
roverId: verifiedPrimaryRover || verifiedAssignmentRover || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,15 +163,7 @@ function buildSession(socket) {
|
||||
const controllableUserCount = countControllableUsers(userEntries);
|
||||
const users = userEntries.map((entry) => ({
|
||||
...entry,
|
||||
/*
|
||||
PTZ is intentionally not a roverManager record, so the normal physical
|
||||
rover visibility filter would erase the user's PTZ chat target. Preserve
|
||||
it here because getPtzChatTargetForSocket already applied the PTZ access
|
||||
and queue/operator rules before buildUserEntry returned it.
|
||||
*/
|
||||
roverId: entry.roverId === PTZ_CAMERA_ID
|
||||
? entry.roverId
|
||||
: filterVisibleRoverId(socket, entry.roverId),
|
||||
roverId: filterVisibleRoverId(socket, entry.roverId),
|
||||
}));
|
||||
const roster = roverManager.getRosterForSocket(socket);
|
||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||
@@ -201,6 +185,7 @@ function buildSession(socket) {
|
||||
return {
|
||||
socketId: socket?.id || null,
|
||||
role: getRole(socket),
|
||||
operatingMode: getOperatingMode(socket),
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||
@@ -210,15 +195,24 @@ function buildSession(socket) {
|
||||
of navigation and layout without maintaining another feature registry.
|
||||
*/
|
||||
features,
|
||||
roster,
|
||||
roster: roster.map((rover) => ({
|
||||
...rover,
|
||||
permissions: { canControl: getRole(socket) !== 'spectator' && canDrive(rover.id, socket) },
|
||||
turn: buildRoverTurn({
|
||||
rover,
|
||||
mode: getMode(),
|
||||
socketId: socket?.id,
|
||||
turnInfo: turnQueues[rover.id],
|
||||
activeDriverId: activeDrivers[rover.id],
|
||||
users,
|
||||
}),
|
||||
})),
|
||||
odometers: roverManager.getOdometersForSocket(socket),
|
||||
assignment: {
|
||||
...assignment,
|
||||
roverId: assignmentRoverId,
|
||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||
},
|
||||
activeDrivers,
|
||||
turnQueues,
|
||||
roomCameras: getRoomCameras(),
|
||||
ptzCamera: getPtzCameraState(socket),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
@@ -280,10 +274,19 @@ function syncAll() {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('session:setOperatingMode', async ({ operatingMode } = {}, cb = () => {}) => {
|
||||
try {
|
||||
cb({ ok: true, ...await setOperatingMode(socket, operatingMode) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
logger.info('New socket connected', socket.id);
|
||||
syncSocket(socket);
|
||||
});
|
||||
|
||||
operatingModeEvents.on('change', () => syncAll());
|
||||
|
||||
roleEvents.on('change', ({ socket }) => {
|
||||
if (!socket) return;
|
||||
logger.info('Role changed; syncing session', socket.id);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user