mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
155
Commits
laptoprover
...
wiifit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
351cb24458 | ||
|
|
679563862d | ||
|
|
8655cde0f1 | ||
|
|
12090f23be | ||
|
|
557b4b81a2 | ||
|
|
0add90714b | ||
|
|
fe64ec7758 | ||
|
|
10f71edaf1 | ||
|
|
e97d4056fa | ||
|
|
c228bb107f | ||
|
|
06aeca660b | ||
|
|
4bd228547a | ||
|
|
35561495b4 | ||
|
|
8a9205b5b7 | ||
|
|
a6c569ada4 | ||
|
|
0d352d326d | ||
|
|
7bc08af160 | ||
|
|
9177e53fbf | ||
|
|
5be5ad3b17 | ||
|
|
99bc00e96b | ||
|
|
002b174259 | ||
|
|
e28ccc5e66 | ||
|
|
efae430d65 | ||
|
|
0c9df78070 | ||
|
|
9d8e22ad1e | ||
|
|
385e7c25fa | ||
|
|
3a8a2ebb13 | ||
|
|
96d06091ee | ||
|
|
15e03e62ed | ||
|
|
3aa97baa4f | ||
|
|
017b3c69d5 | ||
|
|
ad7de34d6d | ||
|
|
51fbee400c | ||
|
|
7fe5730953 | ||
|
|
0d6b4d68de | ||
|
|
1c401ff90a | ||
|
|
f6b9fa798e | ||
|
|
f667bbce53 | ||
|
|
f480e01bf7 | ||
|
|
e2e94da656 | ||
|
|
8ee680ce9f | ||
|
|
be37a39291 | ||
|
|
af484f5099 | ||
|
|
5d8cb48fd0 | ||
|
|
c742fa1c81 | ||
|
|
6dc067580d | ||
|
|
d9e6317220 | ||
|
|
f969e50772 | ||
|
|
7d3f702e32 | ||
|
|
5f3206a065 | ||
|
|
411313b21b | ||
|
|
fa92726e9c | ||
|
|
30b8867b3a | ||
|
|
e254eea9e4 | ||
|
|
60eacf982c | ||
|
|
23108241f1 | ||
|
|
cc525afe20 | ||
|
|
a5884d9eac | ||
|
|
0fc4973cb7 | ||
|
|
7dfdf62c94 | ||
|
|
7286c5b36c | ||
|
|
4360e9ca03 | ||
|
|
dd0ba60d49 | ||
|
|
b5cb9bc8e4 | ||
|
|
fd1b3e103d | ||
|
|
9732f6c080 | ||
|
|
2064c4196c | ||
|
|
2cef95e6e3 | ||
|
|
76318e6b36 | ||
|
|
09ce66e7a4 | ||
|
|
fd1caf2df9 | ||
|
|
58410cd66b | ||
|
|
6d4000bed7 | ||
|
|
6e63f0e19c | ||
|
|
ed24d9ea89 | ||
|
|
b3141e9870 | ||
|
|
64d6d5a601 | ||
|
|
db44d23947 | ||
|
|
924a3c3d55 | ||
|
|
4d66defae0 | ||
|
|
0bb3f89472 | ||
|
|
e4ada54cf4 | ||
|
|
b393c2b2b4 | ||
|
|
18649deeae | ||
|
|
6747658106 | ||
|
|
033a2bae43 | ||
|
|
27dbb068be | ||
|
|
71706fb1b9 | ||
|
|
65b54f01d2 | ||
|
|
026e9de476 | ||
|
|
91bbeb6d8a | ||
|
|
43e4527ad5 | ||
|
|
a07d532043 | ||
|
|
2bd6215be2 | ||
|
|
e02b7a2eb7 | ||
|
|
10a586e5d0 | ||
|
|
775dd7b830 | ||
|
|
f2d3567978 | ||
|
|
10121650de | ||
|
|
9b875aedcb | ||
|
|
6d685c26ba | ||
|
|
dd8e87fda7 | ||
|
|
bf3ce9a28a | ||
|
|
0c0b55fe88 | ||
|
|
c06ac6bc20 | ||
|
|
8ec9ecc8d4 | ||
|
|
02599a44e4 | ||
|
|
4b8aa67c32 | ||
|
|
0e5f76fb2f | ||
|
|
3af74870a5 | ||
|
|
aee1a9d563 | ||
|
|
b3001cf0a3 | ||
|
|
d777e3a48e | ||
|
|
ee393adc8e | ||
|
|
c406ace339 | ||
|
|
6a31d8bc35 | ||
|
|
a6f6ccf079 | ||
|
|
5d60544904 | ||
|
|
3a40a65d46 | ||
|
|
f15ace85f6 | ||
|
|
656bf90e7f | ||
|
|
2d75935e65 | ||
|
|
42c248e298 | ||
|
|
55e8235b02 | ||
|
|
77de628c0b | ||
|
|
f31b21559c | ||
|
|
f7514e71cc | ||
|
|
f4683cd47d | ||
|
|
2beb1498fa | ||
|
|
d349df2432 | ||
|
|
11340bf3f6 | ||
|
|
e6c4931210 | ||
|
|
8f0ac358d6 | ||
|
|
4204a66549 | ||
|
|
a8bff428c2 | ||
|
|
69b49ae1d6 | ||
|
|
07ad43f42f | ||
|
|
f9f87c00d3 | ||
|
|
6f6325f477 | ||
|
|
0b3c7869af | ||
|
|
b526beb712 | ||
|
|
df22ac6d81 | ||
|
|
6c06275c6d | ||
|
|
3aea6d4766 | ||
|
|
3e632ac607 | ||
|
|
d77ec54bc9 | ||
|
|
40e8adf15a | ||
|
|
29bf4cc5d2 | ||
|
|
b083938338 | ||
|
|
5cade7a941 | ||
|
|
00277667d6 | ||
|
|
83a6910c25 | ||
|
|
295d01f7cc | ||
|
|
d8e63bdf2e | ||
|
|
fa4852b93c |
@@ -5,6 +5,8 @@ create_2_Open_Interface_Spec.txt
|
|||||||
|
|
||||||
logs
|
logs
|
||||||
node_modules/
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
.pio
|
.pio
|
||||||
.vscode/
|
.vscode/
|
||||||
config.h
|
config.h
|
||||||
@@ -31,3 +33,4 @@ server/data/identity.sqlite
|
|||||||
server/data/barcode-games.json
|
server/data/barcode-games.json
|
||||||
server/data/identity.sqlite-shm
|
server/data/identity.sqlite-shm
|
||||||
server/data/identity.sqlite-wal
|
server/data/identity.sqlite-wal
|
||||||
|
server/src/services/balanceBoardService/native/balance_board_worker
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ A system for controlling create 2 compatible roombas through a webpage.
|
|||||||
You can explore my basement through this project here:
|
You can explore my basement through this project here:
|
||||||
https://rover.otter.land
|
https://rover.otter.land
|
||||||
|
|
||||||
|
*some of this code was created with help from large language models, and some of it was written by me. This project would not have been possible for me to create without it.*
|
||||||
|
|
||||||
## This guide is a work in progress, it will cover:
|
## This guide is a work in progress, it will cover:
|
||||||
- Building rovers
|
- Building rovers
|
||||||
- Installing roverd on a rover's raspberry pi
|
- Installing roverd on a rover's raspberry pi
|
||||||
|
|||||||
@@ -1,32 +1,40 @@
|
|||||||
# ALSA routing for the Debian laptop rover profile.
|
# Reference ALSA routing for the Debian laptop rover profile.
|
||||||
#
|
#
|
||||||
# This file intentionally mirrors the logical device names used by the Pi rover
|
# This intentionally mirrors pi/asound.conf as closely as a normal PC can:
|
||||||
# audio setup. roverd can keep sending horn audio to "horn", forwarded browser
|
# one fixed hardware card, one dmix playback engine, separate softvol controls
|
||||||
# audio to "forward", and TTS to ALSA's default playback path without caring
|
# for TTS/horn/forwarded audio, and a raw capture alias for the rover mic.
|
||||||
# which physical sound card is underneath the profile.
|
#
|
||||||
|
# The Debian laptop installer no longer copies this file directly. It renders
|
||||||
|
# /etc/asound.conf from /etc/roverd-installer.env so laptops with HDMI as card 0
|
||||||
|
# can point these same logical mixer devices at their real speaker card.
|
||||||
|
#
|
||||||
|
# This is NOT meant to preserve normal desktop audio behavior. The laptop rover
|
||||||
|
# installer disables PipeWire/PulseAudio so roverd owns the audio hardware like
|
||||||
|
# the Raspberry Pi rover does. If the laptop's real speaker/mic card is not ALSA
|
||||||
|
# card 0, rerun the Debian laptop installer and answer the ALSA prompts using:
|
||||||
|
# aplay -l
|
||||||
|
# arecord -l
|
||||||
|
|
||||||
pcm.roverd_playback {
|
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||||
type plug
|
pcm.dmixer {
|
||||||
|
type dmix
|
||||||
# Use the system's first normal ALSA playback device as the physical sink.
|
ipc_key 1024
|
||||||
# This avoids referencing "default" here, because this file replaces
|
ipc_perm 0666
|
||||||
# pcm.!default below and using it as a slave would recurse.
|
slave {
|
||||||
slave.pcm "sysdefault"
|
pcm "hw:0,0"
|
||||||
}
|
format S16_LE
|
||||||
|
rate 16000
|
||||||
pcm.roverd_capture {
|
channels 1
|
||||||
type plug
|
period_time 0
|
||||||
|
period_size 1024
|
||||||
# The media publisher records from "default"; with pcm.!default below that
|
buffer_size 4096
|
||||||
# capture side resolves here. Keeping capture separate from playback lets the
|
}
|
||||||
# asym default expose ordinary microphone input while playback goes through
|
|
||||||
# the TTS softvol path.
|
|
||||||
slave.pcm "sysdefault"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# TTS volume control (used by default playback path).
|
||||||
pcm.tts_softvol {
|
pcm.tts_softvol {
|
||||||
type softvol
|
type softvol
|
||||||
slave.pcm "roverd_playback"
|
slave.pcm "dmixer"
|
||||||
control {
|
control {
|
||||||
name "TTSMaster"
|
name "TTSMaster"
|
||||||
card 0
|
card 0
|
||||||
@@ -35,9 +43,10 @@ pcm.tts_softvol {
|
|||||||
max_dB 12.0
|
max_dB 12.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Horn volume control.
|
||||||
pcm.horn_softvol {
|
pcm.horn_softvol {
|
||||||
type softvol
|
type softvol
|
||||||
slave.pcm "roverd_playback"
|
slave.pcm "dmixer"
|
||||||
control {
|
control {
|
||||||
name "HornMaster"
|
name "HornMaster"
|
||||||
card 0
|
card 0
|
||||||
@@ -46,9 +55,10 @@ pcm.horn_softvol {
|
|||||||
max_dB 12.0
|
max_dB 12.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Forwarded audio volume control.
|
||||||
pcm.forward_softvol {
|
pcm.forward_softvol {
|
||||||
type softvol
|
type softvol
|
||||||
slave.pcm "roverd_playback"
|
slave.pcm "dmixer"
|
||||||
control {
|
control {
|
||||||
name "ForwardMaster"
|
name "ForwardMaster"
|
||||||
card 0
|
card 0
|
||||||
@@ -57,6 +67,7 @@ pcm.forward_softvol {
|
|||||||
max_dB 12.0
|
max_dB 12.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Per-source playback PCMs.
|
||||||
pcm.tts {
|
pcm.tts {
|
||||||
type plug
|
type plug
|
||||||
slave.pcm "tts_softvol"
|
slave.pcm "tts_softvol"
|
||||||
@@ -72,14 +83,17 @@ pcm.forward {
|
|||||||
slave.pcm "forward_softvol"
|
slave.pcm "forward_softvol"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Capture alias used by laptop rover config defaults.
|
||||||
|
pcm.rovermic {
|
||||||
|
type plug
|
||||||
|
slave.pcm "hw:0,0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defaults: TTS direct playback + raw capture on the dedicated laptop sound card.
|
||||||
pcm.!default {
|
pcm.!default {
|
||||||
type asym
|
type asym
|
||||||
|
|
||||||
# Existing TTS engines play to their default ALSA output, so default playback
|
|
||||||
# is intentionally the TTS path. This preserves the current TTS execution
|
|
||||||
# model while still making the TTS volume control meaningful on laptops.
|
|
||||||
playback.pcm "tts"
|
playback.pcm "tts"
|
||||||
capture.pcm "roverd_capture"
|
capture.pcm "rovermic"
|
||||||
}
|
}
|
||||||
|
|
||||||
ctl.!default {
|
ctl.!default {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_ROOT = "/opt/roverd/googletts"
|
||||||
|
LIB_PATH = os.path.join(ASSET_ROOT, "libchrometts.so")
|
||||||
|
VOICE_DIR = os.path.join(ASSET_ROOT, "en-us-x-multi-r30")
|
||||||
|
PIPELINE = "pipeline.pb"
|
||||||
|
PLAYBACK_DEVICE = "tts"
|
||||||
|
SAMPLE_RATE = "24000"
|
||||||
|
MAX_TEXT_CHARS = 512
|
||||||
|
|
||||||
|
VOICES = {
|
||||||
|
"sfg": "female",
|
||||||
|
"iob": "female",
|
||||||
|
"iog": "female",
|
||||||
|
"iol": "male",
|
||||||
|
"iom": "male",
|
||||||
|
"tpc": "female",
|
||||||
|
"tpd": "male",
|
||||||
|
"tpf": "female",
|
||||||
|
}
|
||||||
|
DEFAULT_VOICE = "tpf"
|
||||||
|
DEFAULT_PITCH = 1.0
|
||||||
|
DEFAULT_SPEED = 1.0
|
||||||
|
MIN_PITCH = 0.5
|
||||||
|
MAX_PITCH = 2.0
|
||||||
|
MIN_SPEED = 0.5
|
||||||
|
MAX_SPEED = 2.0
|
||||||
|
|
||||||
|
_runtime_handles = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_shared_library(path):
|
||||||
|
mode = ctypes.RTLD_GLOBAL | getattr(os, "RTLD_NOW", 0)
|
||||||
|
return ctypes.CDLL(path, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
def preload_runtime_libraries():
|
||||||
|
# Laptop-only workaround: some ChromeOS libchrometts builds reference
|
||||||
|
# compiler helper symbols such as __udivmodti4 without declaring the runtime
|
||||||
|
# library as an ELF dependency. Loading common compiler runtimes globally
|
||||||
|
# first makes those symbols visible before ctypes loads libchrometts.so.
|
||||||
|
for name in ("gcc_s", "atomic", "stdc++", "c++", "c++abi"):
|
||||||
|
lib = ctypes.util.find_library(name)
|
||||||
|
if not lib:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_runtime_handles.append(load_shared_library(lib))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
preload_runtime_libraries()
|
||||||
|
|
||||||
|
|
||||||
|
def varint(value):
|
||||||
|
out = bytearray()
|
||||||
|
while value >= 0x80:
|
||||||
|
out.append((value & 0x7F) | 0x80)
|
||||||
|
value >>= 7
|
||||||
|
out.append(value)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def field_bytes(number, payload):
|
||||||
|
return varint((number << 3) | 2) + varint(len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def field_float(number, value):
|
||||||
|
return varint((number << 3) | 5) + struct.pack("<f", float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def build_utterance(text, pitch=1.0, speed=1.0):
|
||||||
|
params = field_float(2, pitch) + field_float(3, speed)
|
||||||
|
msg_b = field_bytes(1, text.encode("utf-8")) + field_bytes(20, params)
|
||||||
|
msg_a = field_bytes(1, msg_b)
|
||||||
|
return field_bytes(1, msg_a)
|
||||||
|
|
||||||
|
|
||||||
|
def build_speaker(name, gender):
|
||||||
|
return field_bytes(1, name.encode("utf-8")) + field_bytes(2, gender.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class ChromeTTS:
|
||||||
|
def __init__(self):
|
||||||
|
self.lib = load_shared_library(LIB_PATH)
|
||||||
|
self.lib.GoogleTtsInit.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||||
|
self.lib.GoogleTtsInit.restype = ctypes.c_bool
|
||||||
|
self.lib.GoogleTtsInitBuffered.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||||
|
self.lib.GoogleTtsInitBuffered.restype = ctypes.c_bool
|
||||||
|
self.lib.GoogleTtsGetFramesInAudioBuffer.argtypes = []
|
||||||
|
self.lib.GoogleTtsGetFramesInAudioBuffer.restype = ctypes.c_size_t
|
||||||
|
self.lib.GoogleTtsReadBuffered.argtypes = [
|
||||||
|
ctypes.POINTER(ctypes.c_float),
|
||||||
|
ctypes.POINTER(ctypes.c_size_t),
|
||||||
|
]
|
||||||
|
self.lib.GoogleTtsReadBuffered.restype = ctypes.c_int
|
||||||
|
self.lib.GoogleTtsShutdown.argtypes = []
|
||||||
|
self.lib.GoogleTtsShutdown.restype = None
|
||||||
|
|
||||||
|
voice_dir = os.path.abspath(VOICE_DIR) + os.sep
|
||||||
|
pipeline = os.path.join(voice_dir, PIPELINE)
|
||||||
|
if not self.lib.GoogleTtsInit(pipeline.encode("utf-8"), voice_dir.encode("utf-8")):
|
||||||
|
raise RuntimeError("GoogleTtsInit failed")
|
||||||
|
self.frames = int(self.lib.GoogleTtsGetFramesInAudioBuffer())
|
||||||
|
if self.frames <= 0:
|
||||||
|
raise RuntimeError("invalid Google TTS audio buffer size")
|
||||||
|
self.buffer = (ctypes.c_float * self.frames)()
|
||||||
|
|
||||||
|
def speak_to_aplay(self, text, voice, pitch=DEFAULT_PITCH, speed=DEFAULT_SPEED):
|
||||||
|
voice = voice if voice in VOICES else DEFAULT_VOICE
|
||||||
|
pitch = clamp_float(pitch, MIN_PITCH, MAX_PITCH, DEFAULT_PITCH)
|
||||||
|
speed = clamp_float(speed, MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("text required")
|
||||||
|
text = text[:MAX_TEXT_CHARS]
|
||||||
|
utterance = build_utterance(text, pitch=pitch, speed=speed)
|
||||||
|
speaker = build_speaker(voice, VOICES[voice])
|
||||||
|
if not self.lib.GoogleTtsInitBuffered(utterance, speaker, len(utterance), len(speaker)):
|
||||||
|
raise RuntimeError("GoogleTtsInitBuffered failed")
|
||||||
|
|
||||||
|
player = subprocess.Popen(
|
||||||
|
["aplay", "-q", "-D", PLAYBACK_DEVICE, "-r", SAMPLE_RATE, "-f", "FLOAT_LE", "-c", "1"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
frames_written = ctypes.c_size_t(0)
|
||||||
|
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
|
||||||
|
frames = int(frames_written.value)
|
||||||
|
if frames > 0:
|
||||||
|
player.stdin.write(ctypes.string_at(self.buffer, frames * ctypes.sizeof(ctypes.c_float)))
|
||||||
|
player.stdin.close()
|
||||||
|
rc = player.wait()
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"aplay exited with {rc}")
|
||||||
|
finally:
|
||||||
|
if player.poll() is None:
|
||||||
|
player.kill()
|
||||||
|
player.wait()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.lib.GoogleTtsShutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def respond(payload):
|
||||||
|
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_float(value, minimum, maximum, fallback):
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
if value <= 0:
|
||||||
|
return fallback
|
||||||
|
if value < minimum:
|
||||||
|
return minimum
|
||||||
|
if value > maximum:
|
||||||
|
return maximum
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
tts = ChromeTTS()
|
||||||
|
except Exception as exc:
|
||||||
|
respond({"ok": False, "error": str(exc)})
|
||||||
|
return 1
|
||||||
|
|
||||||
|
respond({"ok": True, "ready": True})
|
||||||
|
try:
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
tts.speak_to_aplay(
|
||||||
|
str(request.get("text") or ""),
|
||||||
|
str(request.get("voice") or DEFAULT_VOICE),
|
||||||
|
request.get("pitch", DEFAULT_PITCH),
|
||||||
|
request.get("speed", DEFAULT_SPEED),
|
||||||
|
)
|
||||||
|
respond({"ok": True})
|
||||||
|
except Exception as exc:
|
||||||
|
respond({"ok": False, "error": str(exc)})
|
||||||
|
finally:
|
||||||
|
tts.shutdown()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -29,6 +29,15 @@ if [[ "${EUID}" -ne 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${ROVERD_SELF_UPDATE_SYSTEMD:-}" != "1" ]] && command -v systemd-run >/dev/null 2>&1; then
|
||||||
|
exec systemd-run \
|
||||||
|
--unit=roverd-self-update \
|
||||||
|
--collect \
|
||||||
|
--property=Type=exec \
|
||||||
|
--setenv=ROVERD_SELF_UPDATE_SYSTEMD=1 \
|
||||||
|
"$0"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
|
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -86,3 +95,4 @@ log "Repository fast-forward pull complete"
|
|||||||
# drift away from the normal manual install path.
|
# drift away from the normal manual install path.
|
||||||
"$ROVERD_REPO_DIR/pi/install_roverd.sh"
|
"$ROVERD_REPO_DIR/pi/install_roverd.sh"
|
||||||
log "Installer completed successfully"
|
log "Installer completed successfully"
|
||||||
|
systemctl reboot
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ run_pipeline() {
|
|||||||
--framerate "${ROVERD_VIDEO_FPS}" \
|
--framerate "${ROVERD_VIDEO_FPS}" \
|
||||||
--bitrate "${ROVERD_VIDEO_BITRATE}" \
|
--bitrate "${ROVERD_VIDEO_BITRATE}" \
|
||||||
--codec h264 \
|
--codec h264 \
|
||||||
|
--intra 120 \
|
||||||
--profile baseline \
|
--profile baseline \
|
||||||
--denoise auto \
|
--denoise auto \
|
||||||
--nopreview \
|
--nopreview \
|
||||||
|
|||||||
@@ -1,42 +1,425 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
install_debian_laptop_deps() {
|
install_debian_laptop_deps() {
|
||||||
# This profile is deliberately Debian-only. Using apt directly is simpler
|
# The laptop rover is a dedicated appliance, not a normal desktop/laptop audio
|
||||||
# than adding a fake cross-distro layer, and it keeps the installed package
|
# install. Keep this package set intentionally close to the Pi profile so the
|
||||||
# set easy to inspect on the actual rover laptop.
|
# same roverd TTS/playback/capture code paths are available on both targets.
|
||||||
if command -v ffmpeg >/dev/null 2>&1 \
|
if command -v ffmpeg >/dev/null 2>&1 \
|
||||||
&& command -v arecord >/dev/null 2>&1 \
|
&& command -v arecord >/dev/null 2>&1 \
|
||||||
&& command -v aplay >/dev/null 2>&1 \
|
&& command -v aplay >/dev/null 2>&1 \
|
||||||
&& command -v amixer >/dev/null 2>&1 \
|
&& command -v amixer >/dev/null 2>&1 \
|
||||||
&& command -v v4l2-ctl >/dev/null 2>&1 \
|
&& command -v v4l2-ctl >/dev/null 2>&1 \
|
||||||
&& command -v flite >/dev/null 2>&1 \
|
&& command -v flite >/dev/null 2>&1 \
|
||||||
&& command -v espeak >/dev/null 2>&1; then
|
&& command -v espeak >/dev/null 2>&1 \
|
||||||
log "Debian laptop media/audio dependencies already installed; skipping apt install"
|
&& command -v python3 >/dev/null 2>&1 \
|
||||||
|
&& command -v curl >/dev/null 2>&1 \
|
||||||
|
&& command -v xz >/dev/null 2>&1 \
|
||||||
|
&& command -v unzip >/dev/null 2>&1 \
|
||||||
|
&& ldconfig -p 2>/dev/null | grep -q 'libgcc_s\.so\.1' \
|
||||||
|
&& ldconfig -p 2>/dev/null | grep -q 'libstdc\+\+\.so\.6' \
|
||||||
|
&& ldconfig -p 2>/dev/null | grep -q 'libc++\.so\.1' \
|
||||||
|
&& ldconfig -p 2>/dev/null | grep -q 'libc++abi\.so\.1'; then
|
||||||
|
log "Debian laptop media/audio/TTS dependencies already installed; skipping apt install"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Installing Debian laptop dependencies (ffmpeg, ALSA tools, V4L2 tools, flite, espeak)..."
|
log "Installing Debian laptop rover dependencies (ffmpeg, ALSA tools, V4L2 tools, flite/espeak, Chrome TTS runtime deps)..."
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends ffmpeg alsa-utils v4l-utils ca-certificates flite espeak
|
apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1 libc++abi1 \
|
||||||
|
|| apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1-14 libc++abi1-14
|
||||||
|
}
|
||||||
|
|
||||||
|
DEBIAN_LAPTOP_INSTALLER_CONFIG="/etc/roverd-installer.env"
|
||||||
|
|
||||||
|
disable_debian_laptop_desktop_audio_stack() {
|
||||||
|
# This profile is for a dedicated rover laptop. PipeWire/PulseAudio are good
|
||||||
|
# desktop defaults, but they can grab the hardware device and make the rover's
|
||||||
|
# root/systemd ALSA services fail or route through a moving per-user graph.
|
||||||
|
# Mask them globally and kill already-running instances so ALSA owns the box,
|
||||||
|
# which is the closest behavior to the Pi rover appliance setup.
|
||||||
|
log "Disabling desktop audio daemons for dedicated laptop rover audio"
|
||||||
|
|
||||||
|
local -a user_units=(
|
||||||
|
pipewire.service
|
||||||
|
pipewire.socket
|
||||||
|
pipewire-pulse.service
|
||||||
|
pipewire-pulse.socket
|
||||||
|
wireplumber.service
|
||||||
|
pulseaudio.service
|
||||||
|
pulseaudio.socket
|
||||||
|
)
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl --global disable "${user_units[@]}" >/dev/null 2>&1 || true
|
||||||
|
systemctl --global mask "${user_units[@]}" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
pkill -x pipewire >/dev/null 2>&1 || true
|
||||||
|
pkill -x pipewire-pulse >/dev/null 2>&1 || true
|
||||||
|
pkill -x wireplumber >/dev/null 2>&1 || true
|
||||||
|
pkill -x pulseaudio >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
derive_debian_laptop_alsa_card_from_device() {
|
||||||
|
local device="$1"
|
||||||
|
|
||||||
|
# The common ALSA hardware device shape is hw:CARD,DEVICE. Pulling the card
|
||||||
|
# number from that string gives the installer a useful default while still
|
||||||
|
# allowing the prompt to handle named cards or uncommon PCM strings.
|
||||||
|
if [[ "$device" =~ ^hw:([0-9]+),[0-9]+$ ]]; then
|
||||||
|
printf '%s\n' "${BASH_REMATCH[1]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '0\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
read_debian_laptop_installer_value() {
|
||||||
|
local prompt="$1"
|
||||||
|
local default_value="$2"
|
||||||
|
local value=""
|
||||||
|
|
||||||
|
# Prompting through /dev/tty keeps this usable even when the installer is
|
||||||
|
# launched through sudo with stdin redirected. The caller already checks for
|
||||||
|
# an interactive terminal before reaching this function, so failure here is
|
||||||
|
# genuinely unexpected and should stop the install instead of guessing.
|
||||||
|
read -r -p "${prompt} [${default_value}]: " value </dev/tty
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
value="$default_value"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_debian_laptop_alsa_config() {
|
||||||
|
# The PCM fields are written inside quoted ALSA strings, so keep them to the
|
||||||
|
# device spellings ALSA normally uses for hardware/plugin PCMs. Rejecting
|
||||||
|
# whitespace and shell/config punctuation prevents a bad installer config
|
||||||
|
# from generating an asound.conf that changes structure instead of values.
|
||||||
|
if [[ ! "$ROVERD_ALSA_PLAYBACK_DEVICE" =~ ^[A-Za-z0-9_.,:+/-]+$ ]]; then
|
||||||
|
echo "Invalid ROVERD_ALSA_PLAYBACK_DEVICE: $ROVERD_ALSA_PLAYBACK_DEVICE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$ROVERD_ALSA_CAPTURE_DEVICE" =~ ^[A-Za-z0-9_.,:+/-]+$ ]]; then
|
||||||
|
echo "Invalid ROVERD_ALSA_CAPTURE_DEVICE: $ROVERD_ALSA_CAPTURE_DEVICE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Softvol controls and ctl.!default need the playback card, because
|
||||||
|
# TTSMaster, HornMaster, and ForwardMaster are all playback mixer controls.
|
||||||
|
# Keep this numeric to match the prompt and avoid needing quoted ALSA card
|
||||||
|
# ids in the generated config.
|
||||||
|
if [[ ! "$ROVERD_ALSA_PLAYBACK_CARD" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Invalid ROVERD_ALSA_PLAYBACK_CARD: $ROVERD_ALSA_PLAYBACK_CARD" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_debian_laptop_installer_config_file() {
|
||||||
|
local config_path="$1"
|
||||||
|
local line key val
|
||||||
|
|
||||||
|
# Read only the small allowlist this installer owns. Avoid sourcing the file
|
||||||
|
# because it lives in /etc and is meant to be installer data, not shell code.
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
val="${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
val="${val#${val%%[![:space:]]*}}"
|
||||||
|
val="${val%${val##*[![:space:]]}}"
|
||||||
|
if [[ "$val" =~ ^\".*\"$ ]]; then
|
||||||
|
val="${val:1:${#val}-2}"
|
||||||
|
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
||||||
|
val="${val:1:${#val}-2}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$key" in
|
||||||
|
ROVERD_ALSA_PLAYBACK_DEVICE|ROVERD_ALSA_PLAYBACK_CARD|ROVERD_ALSA_CAPTURE_DEVICE)
|
||||||
|
printf -v "$key" '%s' "$val"
|
||||||
|
export "$key"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < "$config_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_debian_laptop_installer_config_file() {
|
||||||
|
local config_path="$1"
|
||||||
|
local tmp_path
|
||||||
|
|
||||||
|
tmp_path="$(mktemp)"
|
||||||
|
# This file is intentionally plain KEY=VALUE shell-style data so future
|
||||||
|
# installs can reuse the same laptop-specific card choices without asking
|
||||||
|
# again. It is still parsed by an allowlist reader instead of sourced.
|
||||||
|
cat > "$tmp_path" <<EOF
|
||||||
|
# Created by install_roverd.sh for the Debian laptop rover profile.
|
||||||
|
# These values choose the physical ALSA hardware behind the rover's logical
|
||||||
|
# mixer devices: tts, horn, forward, default playback, and rovermic capture.
|
||||||
|
ROVERD_ALSA_PLAYBACK_DEVICE="${ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||||
|
ROVERD_ALSA_PLAYBACK_CARD="${ROVERD_ALSA_PLAYBACK_CARD}"
|
||||||
|
ROVERD_ALSA_CAPTURE_DEVICE="${ROVERD_ALSA_CAPTURE_DEVICE}"
|
||||||
|
EOF
|
||||||
|
install -o root -g root -m 0644 "$tmp_path" "$config_path"
|
||||||
|
rm -f "$tmp_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_or_create_debian_laptop_alsa_config() {
|
||||||
|
if [[ -f "$DEBIAN_LAPTOP_INSTALLER_CONFIG" ]]; then
|
||||||
|
load_debian_laptop_installer_config_file "$DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||||
|
log "Using Debian laptop ALSA installer config from $DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||||
|
elif [[ -n "${ROVERD_ALSA_PLAYBACK_DEVICE:-}" && -n "${ROVERD_ALSA_PLAYBACK_CARD:-}" && -n "${ROVERD_ALSA_CAPTURE_DEVICE:-}" ]]; then
|
||||||
|
# This keeps unattended installs possible without adding a pile of CLI
|
||||||
|
# flags. The generated /etc file still becomes the durable source for
|
||||||
|
# future installs on the same laptop.
|
||||||
|
validate_debian_laptop_alsa_config
|
||||||
|
write_debian_laptop_installer_config_file "$DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||||
|
log "Wrote Debian laptop ALSA installer config to $DEBIAN_LAPTOP_INSTALLER_CONFIG from environment"
|
||||||
|
else
|
||||||
|
if ! { true </dev/tty >/dev/tty; } 2>/dev/null; then
|
||||||
|
echo "Missing $DEBIAN_LAPTOP_INSTALLER_CONFIG and no interactive terminal is available for ALSA setup." >&2
|
||||||
|
echo "Run sudo ./pi/install_roverd.sh --debian-laptop once from a terminal, then reuse the generated config for future installs." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "No $DEBIAN_LAPTOP_INSTALLER_CONFIG found; creating Debian laptop ALSA installer config"
|
||||||
|
if command -v aplay >/dev/null 2>&1; then
|
||||||
|
echo "Playback devices from aplay -l:" >/dev/tty
|
||||||
|
aplay -l >/dev/tty 2>/dev/tty || true
|
||||||
|
fi
|
||||||
|
if command -v arecord >/dev/null 2>&1; then
|
||||||
|
echo "Capture devices from arecord -l:" >/dev/tty
|
||||||
|
arecord -l >/dev/tty 2>/dev/tty || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROVERD_ALSA_PLAYBACK_DEVICE="$(read_debian_laptop_installer_value "ALSA playback device for rover speaker output" "${ROVERD_ALSA_PLAYBACK_DEVICE:-hw:0,0}")"
|
||||||
|
ROVERD_ALSA_PLAYBACK_CARD="$(read_debian_laptop_installer_value "ALSA playback card number for mixer controls" "${ROVERD_ALSA_PLAYBACK_CARD:-$(derive_debian_laptop_alsa_card_from_device "$ROVERD_ALSA_PLAYBACK_DEVICE")}")"
|
||||||
|
ROVERD_ALSA_CAPTURE_DEVICE="$(read_debian_laptop_installer_value "ALSA capture device for rover microphone input" "${ROVERD_ALSA_CAPTURE_DEVICE:-$ROVERD_ALSA_PLAYBACK_DEVICE}")"
|
||||||
|
|
||||||
|
validate_debian_laptop_alsa_config
|
||||||
|
write_debian_laptop_installer_config_file "$DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||||
|
log "Wrote Debian laptop ALSA installer config to $DEBIAN_LAPTOP_INSTALLER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROVERD_ALSA_PLAYBACK_DEVICE="${ROVERD_ALSA_PLAYBACK_DEVICE:-hw:0,0}"
|
||||||
|
ROVERD_ALSA_PLAYBACK_CARD="${ROVERD_ALSA_PLAYBACK_CARD:-$(derive_debian_laptop_alsa_card_from_device "$ROVERD_ALSA_PLAYBACK_DEVICE")}"
|
||||||
|
ROVERD_ALSA_CAPTURE_DEVICE="${ROVERD_ALSA_CAPTURE_DEVICE:-$ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||||
|
validate_debian_laptop_alsa_config
|
||||||
|
}
|
||||||
|
|
||||||
|
render_debian_laptop_asound_config() {
|
||||||
|
local tmp_path
|
||||||
|
|
||||||
|
tmp_path="$(mktemp)"
|
||||||
|
# The rover-facing ALSA names stay stable even when the laptop's physical
|
||||||
|
# sound card changes. dmixer owns the one real playback PCM, while tts,
|
||||||
|
# horn, and forward each wrap that mixer with a separate softvol control.
|
||||||
|
cat > "$tmp_path" <<EOF
|
||||||
|
# Dedicated ALSA routing for the Debian laptop rover profile.
|
||||||
|
#
|
||||||
|
# Generated by install_roverd.sh from $DEBIAN_LAPTOP_INSTALLER_CONFIG.
|
||||||
|
# Change the physical devices there, then rerun the Debian laptop installer.
|
||||||
|
#
|
||||||
|
# Logical playback devices:
|
||||||
|
# tts - default text-to-speech output with TTSMaster softvol
|
||||||
|
# horn - horn synth output with HornMaster softvol
|
||||||
|
# forward - browser-forwarded audio with ForwardMaster softvol
|
||||||
|
# default - TTS playback plus rovermic capture
|
||||||
|
#
|
||||||
|
# Physical routing selected for this laptop:
|
||||||
|
# playback PCM: ${ROVERD_ALSA_PLAYBACK_DEVICE}
|
||||||
|
# playback card: ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||||
|
# capture PCM: ${ROVERD_ALSA_CAPTURE_DEVICE}
|
||||||
|
|
||||||
|
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||||
|
pcm.dmixer {
|
||||||
|
type dmix
|
||||||
|
ipc_key 1024
|
||||||
|
ipc_perm 0666
|
||||||
|
slave {
|
||||||
|
pcm "${ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||||
|
format S16_LE
|
||||||
|
rate 16000
|
||||||
|
channels 1
|
||||||
|
period_time 0
|
||||||
|
period_size 1024
|
||||||
|
buffer_size 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# TTS volume control. TTS uses the default playback route, so this control lets
|
||||||
|
# generated speech move independently from horns and forwarded browser audio.
|
||||||
|
pcm.tts_softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "dmixer"
|
||||||
|
control {
|
||||||
|
name "TTSMaster"
|
||||||
|
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||||
|
}
|
||||||
|
min_dB -60.0
|
||||||
|
max_dB 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Horn volume control. The horn synth opens the logical "horn" device, which
|
||||||
|
# keeps horn loudness adjustable without changing the shared hardware PCM.
|
||||||
|
pcm.horn_softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "dmixer"
|
||||||
|
control {
|
||||||
|
name "HornMaster"
|
||||||
|
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||||
|
}
|
||||||
|
min_dB -60.0
|
||||||
|
max_dB 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Forwarded audio volume control. The browser-audio listener opens "forward",
|
||||||
|
# so remote audio can be mixed with local rover sounds without bypassing dmix.
|
||||||
|
pcm.forward_softvol {
|
||||||
|
type softvol
|
||||||
|
slave.pcm "dmixer"
|
||||||
|
control {
|
||||||
|
name "ForwardMaster"
|
||||||
|
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||||
|
}
|
||||||
|
min_dB -60.0
|
||||||
|
max_dB 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-source playback PCMs.
|
||||||
|
pcm.tts {
|
||||||
|
type plug
|
||||||
|
slave.pcm "tts_softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
pcm.horn {
|
||||||
|
type plug
|
||||||
|
slave.pcm "horn_softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
pcm.forward {
|
||||||
|
type plug
|
||||||
|
slave.pcm "forward_softvol"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Capture alias used by laptop rover config defaults. Capture is deliberately
|
||||||
|
# separate from playback because laptop speakers and microphones often appear
|
||||||
|
# on different ALSA cards.
|
||||||
|
pcm.rovermic {
|
||||||
|
type plug
|
||||||
|
slave.pcm "${ROVERD_ALSA_CAPTURE_DEVICE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defaults: TTS direct playback + raw capture on the selected laptop devices.
|
||||||
|
pcm.!default {
|
||||||
|
type asym
|
||||||
|
playback.pcm "tts"
|
||||||
|
capture.pcm "rovermic"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctl.!default {
|
||||||
|
type hw
|
||||||
|
card ${ROVERD_ALSA_PLAYBACK_CARD}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
install -o root -g root -m 0644 "$tmp_path" /etc/asound.conf
|
||||||
|
rm -f "$tmp_path"
|
||||||
|
log "Installed dedicated Debian laptop ALSA config to /etc/asound.conf using playback ${ROVERD_ALSA_PLAYBACK_DEVICE}"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_debian_laptop_audio_support() {
|
install_debian_laptop_audio_support() {
|
||||||
if [[ ! -f pi/asound.debian-laptop.conf ]]; then
|
load_or_create_debian_laptop_alsa_config
|
||||||
log "WARNING: pi/asound.debian-laptop.conf missing; skipping Debian laptop ALSA config install"
|
render_debian_laptop_asound_config
|
||||||
|
|
||||||
|
install -D -o root -g root -m 0755 pi/bin/chromegtts-daemon-laptop.py /usr/local/bin/chromegtts-daemon
|
||||||
|
log "Installed laptop chromegtts daemon"
|
||||||
|
|
||||||
|
install_google_tts_assets_laptop
|
||||||
|
|
||||||
|
log "ALSA config updated; reboot recommended before testing laptop rover audio"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_google_tts_assets_laptop() {
|
||||||
|
local asset_dir="/opt/roverd/googletts"
|
||||||
|
local voice_dir="${asset_dir}/en-us-x-multi-r30"
|
||||||
|
local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz"
|
||||||
|
local tmp_dir
|
||||||
|
local lib_member=""
|
||||||
|
local member
|
||||||
|
|
||||||
|
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
|
||||||
|
log "Google Chrome TTS assets already installed; skipping download"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The laptop profile still uses roverd's existing audio contract: TTS plays
|
tmp_dir="$(mktemp -d)"
|
||||||
# to ALSA's default output, horn plays to the named "horn" device, and
|
log "Downloading Google Chrome TTS assets for Debian laptop profile..."
|
||||||
# forwarded web audio plays to the named "forward" device. Installing one
|
if ! curl -L -o "${tmp_dir}/googletts-26.5.tar.xz" "$dist_url"; then
|
||||||
# profile-specific asound.conf gives those paths independent softvol mixer
|
rm -rf "$tmp_dir"
|
||||||
# controls without changing the TTS runtime code.
|
log "WARNING: failed to download Google Chrome TTS assets; chromegtts will be unavailable"
|
||||||
install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf
|
return
|
||||||
log "Installed Debian laptop ALSA config to /etc/asound.conf"
|
fi
|
||||||
log "ALSA config updated; restarting audio clients or rebooting is recommended before testing laptop audio"
|
|
||||||
|
local -a candidate_libs=()
|
||||||
|
case "$(uname -m)" in
|
||||||
|
aarch64|arm64)
|
||||||
|
candidate_libs=(libchrometts_arm64.so)
|
||||||
|
;;
|
||||||
|
armv7l|armhf)
|
||||||
|
candidate_libs=(libchrometts_armv7.so)
|
||||||
|
;;
|
||||||
|
x86_64|amd64)
|
||||||
|
candidate_libs=(libchrometts_x86_64.so libchrometts_amd64.so libchrometts_x64.so libchrometts.so)
|
||||||
|
;;
|
||||||
|
i386|i686)
|
||||||
|
candidate_libs=(libchrometts_x86.so libchrometts_i386.so libchrometts.so)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "WARNING: unsupported Chrome TTS architecture $(uname -m); skipping Google TTS assets"
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for member in "${candidate_libs[@]}"; do
|
||||||
|
if tar -tf "${tmp_dir}/googletts-26.5.tar.xz" "$member" >/dev/null 2>&1; then
|
||||||
|
lib_member="$member"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$lib_member" ]]; then
|
||||||
|
log "WARNING: no libchrometts library matching $(uname -m) found in Google TTS archive; chromegtts will be unavailable"
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! tar -xf "${tmp_dir}/googletts-26.5.tar.xz" -C "$tmp_dir" en-us-x-multi.zvoice "$lib_member"; then
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
log "WARNING: failed to unpack Google Chrome TTS assets; chromegtts will be unavailable"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -o root -g root -m 0755 "$asset_dir"
|
||||||
|
install -o root -g root -m 0644 "${tmp_dir}/${lib_member}" "${asset_dir}/libchrometts.so"
|
||||||
|
rm -rf "$voice_dir"
|
||||||
|
install -d -o root -g root -m 0755 "$voice_dir"
|
||||||
|
unzip -q "${tmp_dir}/en-us-x-multi.zvoice" -d "$voice_dir"
|
||||||
|
chown -R root:root "$asset_dir"
|
||||||
|
find "$asset_dir" -type d -exec chmod 0755 {} +
|
||||||
|
find "$asset_dir" -type f -exec chmod 0644 {} +
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
log "Installed Google Chrome TTS assets to $asset_dir using $lib_member"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_debian_laptop_profile() {
|
install_debian_laptop_profile() {
|
||||||
install_debian_laptop_deps
|
install_debian_laptop_deps
|
||||||
|
disable_debian_laptop_desktop_audio_stack
|
||||||
install_debian_laptop_audio_support
|
install_debian_laptop_audio_support
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ type PrivateSafetyConfig struct {
|
|||||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||||
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
||||||
|
VirtualWallEnabled bool `yaml:"virtualWallEnabled" json:"virtualWallEnabled"`
|
||||||
|
VirtualWallBackoffSpeed int `yaml:"virtualWallBackoffSpeed" json:"virtualWallBackoffSpeed"`
|
||||||
|
VirtualWallBackoffMs int `yaml:"virtualWallBackoffMs" json:"virtualWallBackoffMs"`
|
||||||
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +316,9 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
CliffEnabled: false,
|
CliffEnabled: false,
|
||||||
CliffBackoffSpeed: 250,
|
CliffBackoffSpeed: 250,
|
||||||
CliffBackoffMs: 500,
|
CliffBackoffMs: 500,
|
||||||
|
VirtualWallEnabled: true,
|
||||||
|
VirtualWallBackoffSpeed: 250,
|
||||||
|
VirtualWallBackoffMs: 500,
|
||||||
TriggerCooldownMs: 800,
|
TriggerCooldownMs: 800,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,4 +95,10 @@ private:
|
|||||||
cliffEnabled: false
|
cliffEnabled: false
|
||||||
cliffBackoffSpeed: 250
|
cliffBackoffSpeed: 250
|
||||||
cliffBackoffMs: 500
|
cliffBackoffMs: 500
|
||||||
|
# Virtual walls are default-on for private rovers because they mark a
|
||||||
|
# deliberate boundary, and the server can escape by reversing the last
|
||||||
|
# commanded wheel directions instead of always backing straight up.
|
||||||
|
virtualWallEnabled: true
|
||||||
|
virtualWallBackoffSpeed: 250
|
||||||
|
virtualWallBackoffMs: 500
|
||||||
triggerCooldownMs: 800
|
triggerCooldownMs: 800
|
||||||
|
|||||||
@@ -104,4 +104,10 @@ private:
|
|||||||
cliffEnabled: false
|
cliffEnabled: false
|
||||||
cliffBackoffSpeed: 250
|
cliffBackoffSpeed: 250
|
||||||
cliffBackoffMs: 500
|
cliffBackoffMs: 500
|
||||||
|
# Virtual walls are default-on for private rovers because they mark a
|
||||||
|
# deliberate boundary, and the server can escape by reversing the last
|
||||||
|
# commanded wheel directions instead of always backing straight up.
|
||||||
|
virtualWallEnabled: true
|
||||||
|
virtualWallBackoffSpeed: 250
|
||||||
|
virtualWallBackoffMs: 500
|
||||||
triggerCooldownMs: 800
|
triggerCooldownMs: 800
|
||||||
|
|||||||
@@ -69,4 +69,10 @@ private:
|
|||||||
cliffEnabled: false
|
cliffEnabled: false
|
||||||
cliffBackoffSpeed: 250
|
cliffBackoffSpeed: 250
|
||||||
cliffBackoffMs: 500
|
cliffBackoffMs: 500
|
||||||
|
# Virtual walls are default-on for private rovers because they mark a
|
||||||
|
# deliberate boundary, and the server can escape by reversing the last
|
||||||
|
# commanded wheel directions instead of always backing straight up.
|
||||||
|
virtualWallEnabled: true
|
||||||
|
virtualWallBackoffSpeed: 250
|
||||||
|
virtualWallBackoffMs: 500
|
||||||
triggerCooldownMs: 800
|
triggerCooldownMs: 800
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Command system and optional Discord feature
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Commands were originally implemented as part of the Discord bot. Web chat support was later added by adapting site chat messages into Discord-shaped messages and reusing the Discord command router. This leaves an important server capability owned by an optional external integration and creates inconsistent behavior between transports.
|
||||||
|
|
||||||
|
The command system should instead be an always-available server capability. Web chat and Discord should both be adapters for the same command system, while Discord itself becomes an optional feature that can be disabled without affecting commands or the rest of the server.
|
||||||
|
|
||||||
|
This is an internal architecture change. Existing behavior on the outside must remain unchanged unless this plan explicitly introduces a new command.
|
||||||
|
|
||||||
|
## Non-negotiable behavior
|
||||||
|
|
||||||
|
- Existing command names and syntax continue to work.
|
||||||
|
- Existing permission and lockdown rules continue to work.
|
||||||
|
- Existing web-chat command messages and replies continue to look and behave the same.
|
||||||
|
- Existing Discord replies and embeds retain the same content, titles, field ordering, colors, timestamps, mention behavior, attachment names, progress updates, and edit behavior.
|
||||||
|
- Existing Discord chat bridge, presence, moderation workflows, announcements, and other integrations continue to work when Discord is enabled.
|
||||||
|
- Disabling Discord does not disable site-chat commands, replay generation, or unrelated server features.
|
||||||
|
- Discord.js types, messages, embeds, guilds, channels, and configuration do not leak into the shared command implementation.
|
||||||
|
- Replay hosting requires no new configuration. It must be automatic, conservative, and functional.
|
||||||
|
- Backwards compatibility for obsolete internal architecture is not required after migration. Temporary migration adapters should be deleted when the new path is complete.
|
||||||
|
|
||||||
|
## Target dependency direction
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web chat adapter ---------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Operator command service ----> Existing server services
|
||||||
|
^
|
||||||
|
|
|
||||||
|
Optional Discord adapter -+
|
||||||
|
```
|
||||||
|
|
||||||
|
The operator command service owns parsing, command discovery, permission policy, execution, and neutral results. It does not know how a web-chat message or Discord message is represented.
|
||||||
|
|
||||||
|
The name `operatorCommandService` avoids confusion with the existing rover `commandService`, which sends operational commands to individual rovers.
|
||||||
|
|
||||||
|
## Command configuration
|
||||||
|
|
||||||
|
Command naming belongs to the command system rather than Discord:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
commands:
|
||||||
|
prefix: "rs"
|
||||||
|
timeStatusCommand: "ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both web chat and Discord must read these same values. Prefix matching remains case-insensitive and must match a whole token so a prefix such as `rs` does not treat a word such as `rsvp` as a command.
|
||||||
|
|
||||||
|
Discord becomes an explicitly optional feature:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
discord:
|
||||||
|
enabled: false
|
||||||
|
token: ""
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing Discord channel, role, site URL, and other settings stay under `discord`. `discord.enabled` is authoritative: a stored token must not silently enable the feature. If Discord is enabled but required credentials are missing or login fails, the failure is clearly logged and must not prevent the rest of the server from operating.
|
||||||
|
|
||||||
|
### Existing server feature system is authoritative
|
||||||
|
|
||||||
|
Use `server/src/helpers/features.js` as the single source of truth for whether optional features are configured and enabled. Do not add a command-specific feature registry, duplicate configuration checks inside command handlers, or infer availability independently from individual config fields.
|
||||||
|
|
||||||
|
- Add Discord to `buildFeatureFlags()` using the same explicit feature-gating pattern as the other optional server features. Discord is enabled only when `discord.enabled` is explicitly true and the required token is present.
|
||||||
|
- Discord service bootstrap, command-adapter registration, integrations, presence, bridge behavior, alerts, and Discord replay delivery all consult the shared Discord feature flag.
|
||||||
|
- Command definitions use `requiredFeature` metadata, and the command dispatcher resolves that metadata through `isFeatureEnabled()` or a feature-flags snapshot from the same helper.
|
||||||
|
- Help availability and command execution use the same feature result so help cannot advertise a command as available when execution considers it disabled.
|
||||||
|
- Lift and Neato availability comes from the existing `lift` and `neato` feature flags. Commands must not reproduce their Home Assistant, switch, device, or enabled-field checks.
|
||||||
|
- Configuration-level feature availability is separate from runtime health. For example, an enabled lift may currently be disconnected, and configured Discord may fail login. The shared feature helper answers whether the feature is enabled and configured; the owning service remains authoritative for runtime readiness and returns a clear operational failure.
|
||||||
|
- Replay generation and automatic local replay hosting are core server capabilities and are not feature-gated. Only the optional Discord delivery provider depends on the Discord feature flag and live Discord readiness.
|
||||||
|
|
||||||
|
When Discord is disabled:
|
||||||
|
|
||||||
|
- Do not construct a Discord client.
|
||||||
|
- Do not attempt login.
|
||||||
|
- Do not register Discord event handlers or event-bus integrations.
|
||||||
|
- Do not register Discord chat bridge subscriptions.
|
||||||
|
- Do not start Discord presence behavior.
|
||||||
|
- Keep the shared command service and all site-chat commands active.
|
||||||
|
|
||||||
|
## Neutral command request
|
||||||
|
|
||||||
|
Every transport converts its native user/message state into one normalized request:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
text: 'rs lock alpha',
|
||||||
|
source: 'web-chat',
|
||||||
|
actor: {
|
||||||
|
id: 'stable actor id',
|
||||||
|
label: 'display name',
|
||||||
|
role: 'admin',
|
||||||
|
isAdmin: true,
|
||||||
|
isLockdownAdmin: false,
|
||||||
|
},
|
||||||
|
context: {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The web adapter derives the actor from the authenticated socket, identity, and role services. The Discord adapter derives it from the Discord user and configured administrator mapping. Command handlers consume the normalized actor and never inspect a socket or `message.author`.
|
||||||
|
|
||||||
|
Transport-specific context is allowed only for transport-specific extension commands. For example, the Discord-only bridge command needs guild and channel context, but shared commands must not depend on it.
|
||||||
|
|
||||||
|
## Command registry
|
||||||
|
|
||||||
|
Replace the large dispatcher switch and scattered help definitions with a command registry. A command definition should contain enough metadata to drive parsing, authorization, availability, and help:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
name: 'lift',
|
||||||
|
category: 'feature',
|
||||||
|
summary: 'Control the rover lift.',
|
||||||
|
description: 'Show lift state or request upward or downward movement.',
|
||||||
|
usage: ['lift status', 'lift up', 'lift down'],
|
||||||
|
examples: ['rs lift status', 'rs lift down'],
|
||||||
|
access: 'admin',
|
||||||
|
lockdownAccess: 'lockdown-admin',
|
||||||
|
requiredFeature: 'lift',
|
||||||
|
execute,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The dispatcher should be responsible for common authorization. Individual handlers may perform finer-grained checks when subcommands truly require different access, but they should not duplicate the ordinary admin and lockdown gates.
|
||||||
|
|
||||||
|
## Command categories
|
||||||
|
|
||||||
|
Categories organize registration and help. Existing syntax must not be changed merely to add categories; for example, `rs mode` stays `rs mode` rather than becoming `rs admin mode`.
|
||||||
|
|
||||||
|
### System commands
|
||||||
|
|
||||||
|
General server information and server-wide user actions:
|
||||||
|
|
||||||
|
- `rs help`
|
||||||
|
- `rs status`
|
||||||
|
- `rs replay`
|
||||||
|
- The configured time-status command, currently `ts`
|
||||||
|
- Future health, session, or informational commands that do not belong to one optional feature
|
||||||
|
|
||||||
|
### Admin commands
|
||||||
|
|
||||||
|
Operational, access, and moderation controls:
|
||||||
|
|
||||||
|
- `rs lock`
|
||||||
|
- `rs unlock`
|
||||||
|
- `rs mode`
|
||||||
|
- `rs kick`
|
||||||
|
- `rs goal`
|
||||||
|
- `rs reason`
|
||||||
|
- `rs verify`
|
||||||
|
- `rs deter`
|
||||||
|
- `rs lights`
|
||||||
|
|
||||||
|
Existing admin and lockdown-admin policies remain authoritative.
|
||||||
|
|
||||||
|
### Feature commands
|
||||||
|
|
||||||
|
Commands belonging to optional hardware or server features. Initial additions should include:
|
||||||
|
|
||||||
|
- `rs lift status`
|
||||||
|
- `rs lift up`
|
||||||
|
- `rs lift down`
|
||||||
|
- `rs neato status`
|
||||||
|
- `rs neato start`
|
||||||
|
- `rs neato home`
|
||||||
|
- `rs neato locate`
|
||||||
|
- `rs neato clear-errors`
|
||||||
|
|
||||||
|
Feature command handlers must call the existing feature services. They must not reimplement lift interlocks, cooldowns, connectivity checks, Home Assistant calls, Neato state rules, or other hardware safety logic. The feature service remains the source of truth and the command reports its result.
|
||||||
|
|
||||||
|
The dispatcher checks each command's `requiredFeature` against the existing server feature system before execution. The owning feature service then performs runtime availability and safety checks. This deliberately keeps configuration eligibility centralized in `helpers/features.js` while keeping live device state and operational rules inside the service that controls the feature.
|
||||||
|
|
||||||
|
Commands for an unavailable or disabled feature return a clear unavailable response rather than throwing or silently doing nothing.
|
||||||
|
|
||||||
|
### Discord-only commands
|
||||||
|
|
||||||
|
Discord bridge configuration is not a general server command. Keep `bridge` as a Discord extension command registered by the Discord adapter:
|
||||||
|
|
||||||
|
- `rs bridge`
|
||||||
|
- `rs bridge here`
|
||||||
|
- `rs bridge mode`
|
||||||
|
- `rs bridge off`
|
||||||
|
|
||||||
|
These commands retain their current syntax and Discord behavior but do not appear as available commands in web chat.
|
||||||
|
|
||||||
|
## Organized help
|
||||||
|
|
||||||
|
Help is generated from registry metadata so command definitions and documentation cannot drift apart.
|
||||||
|
|
||||||
|
The default help should be detailed but scannable, grouped into System, Admin, and Features. Discord-only commands can appear in a Discord section when help is requested from Discord. Help should respect the configured prefix and time-status command.
|
||||||
|
|
||||||
|
Support focused help:
|
||||||
|
|
||||||
|
- `rs help system`
|
||||||
|
- `rs help admin`
|
||||||
|
- `rs help features`
|
||||||
|
- `rs help status`
|
||||||
|
- `rs help replay`
|
||||||
|
- `rs help lift`
|
||||||
|
- `rs help neato`
|
||||||
|
- The same pattern for every registered command
|
||||||
|
|
||||||
|
Focused command help should include:
|
||||||
|
|
||||||
|
- A clear description
|
||||||
|
- Required permission level
|
||||||
|
- Availability or required feature
|
||||||
|
- Accepted usage forms
|
||||||
|
- Useful examples
|
||||||
|
- Subcommand explanations where applicable
|
||||||
|
|
||||||
|
The registry provides neutral help data. Web chat renders readable plain text. Discord uses its own renderer and must preserve the established outward style. Improving organization must not accidentally change unrelated Discord embeds such as rover status and time status.
|
||||||
|
|
||||||
|
## Neutral command results and transport rendering
|
||||||
|
|
||||||
|
Shared handlers return neutral results instead of calling `message.reply()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
handled: true,
|
||||||
|
ok: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
text: 'Locked Alpha.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Simple commands should return text results. Structured results should be used only where transports benefit from different faithful presentations, such as rover status, time status, help, administrative lists, or replay progress.
|
||||||
|
|
||||||
|
Discord renderers translate neutral results into the same Discord.js reply and embed objects used today. Existing embed builders should be extracted and retained where possible instead of visually rewriting them during this architecture change.
|
||||||
|
|
||||||
|
The web adapter translates the same results into the existing `Rover bot` system messages. The current behavior where the user's command remains visible in the chat transcript should remain unchanged.
|
||||||
|
|
||||||
|
## Replay architecture
|
||||||
|
|
||||||
|
Replay generation and replay delivery are separate responsibilities:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Replay request
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Replay engine builds one completed MP4
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Replay delivery coordinator
|
||||||
|
|-- Discord is enabled, ready, and replay channel works
|
||||||
|
| -> upload MP4 to Discord
|
||||||
|
| -> use returned Discord attachment URL
|
||||||
|
|
|
||||||
|
`-- Discord unavailable, unconfigured, or upload fails
|
||||||
|
-> store MP4 under the server data directory
|
||||||
|
-> use server-hosted media URL
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Publish the playable replay media payload to clients
|
||||||
|
```
|
||||||
|
|
||||||
|
Discord remains the preferred host when it is configured for replay delivery. A Discord upload failure after a successful replay build must fall back to local hosting instead of failing the replay. The Discord failure should be logged clearly, while clients still receive a working replay.
|
||||||
|
|
||||||
|
The common client media payload should remain compatible with the current payload so `/mini`, `/display`, spectator clients, and other replay consumers behave the same. Discord-specific metadata remains present when Discord hosted the media. Locally hosted media supplies the same common playable URL and media fields without pretending to be a Discord attachment.
|
||||||
|
|
||||||
|
### Automatic local replay hosting
|
||||||
|
|
||||||
|
No replay-hosting configuration is added. Use conservative internal constants chosen after checking typical generated replay sizes.
|
||||||
|
|
||||||
|
The local media service should:
|
||||||
|
|
||||||
|
- Store completed files in `data/replays/` through the canonical data-directory helper.
|
||||||
|
- Use random, non-guessable IDs in public filenames.
|
||||||
|
- Expose a deliberate route such as `/media/replays/:id.mp4` rather than placing runtime media in built web assets.
|
||||||
|
- Support HTTP range requests so browsers can seek and play MP4 files normally.
|
||||||
|
- Set the correct media type and safe cache headers.
|
||||||
|
- Write atomically by completing a temporary file and renaming it into place.
|
||||||
|
- Never expose or delete a file that is still being written.
|
||||||
|
- Remove abandoned temporary files.
|
||||||
|
- Delete expired replay files during server startup.
|
||||||
|
- Run one lightweight periodic cleanup while the server is running.
|
||||||
|
- Stop the cleanup timer during graceful shutdown if the server has a shutdown lifecycle.
|
||||||
|
- Enforce both a conservative age limit and a conservative total storage ceiling.
|
||||||
|
- Delete the oldest completed files first when the storage ceiling is exceeded.
|
||||||
|
- Treat cleanup errors as logged, nonfatal maintenance failures.
|
||||||
|
- Prevent path traversal and serve only known replay filenames from the replay directory.
|
||||||
|
|
||||||
|
Cleanup must operate only on the hosted replay directory and must not touch replay frame caches, unrelated data files, or active replay builds.
|
||||||
|
|
||||||
|
## Optional Discord feature boundary
|
||||||
|
|
||||||
|
The Discord feature owns:
|
||||||
|
|
||||||
|
- Discord client creation and login
|
||||||
|
- Intents and partials
|
||||||
|
- Discord message-to-command adaptation
|
||||||
|
- Neutral-result-to-Discord rendering
|
||||||
|
- Existing embed presentation
|
||||||
|
- Discord replay upload delivery
|
||||||
|
- Chat bridge and webhook behavior
|
||||||
|
- Guild bridge storage and bridge commands
|
||||||
|
- Presence
|
||||||
|
- Discord announcements and alerts
|
||||||
|
- DM verification and private-access moderation workflows
|
||||||
|
- Reactions and Discord event handling
|
||||||
|
|
||||||
|
Discord must be added to and activated through the existing server feature system. The Discord entrypoint must not maintain a separate interpretation of `discord.enabled` and token availability. Runtime client readiness may still be tracked inside the Discord feature for operations such as replay upload, but that readiness supplements rather than replaces the shared configuration feature flag.
|
||||||
|
|
||||||
|
The Discord feature may import the operator command service. The operator command service, replay engine, chat service, and feature command handlers must not import the Discord feature or Discord.js.
|
||||||
|
|
||||||
|
## Focused regression protection
|
||||||
|
|
||||||
|
The existing implementation is the reference for current command wording and behavior. Read and preserve that behavior while moving each handler; do not first catalogue every reply or build exhaustive snapshots for all commands.
|
||||||
|
|
||||||
|
Use focused tests and practical checks at the boundaries most likely to cause meaningful regressions:
|
||||||
|
|
||||||
|
- Discord status and time-status embeds retain their existing content, structure, colors, field order, timestamps, and links.
|
||||||
|
- Discord replay progress edits, attachment upload, filename, URL extraction, and client media publication continue to work.
|
||||||
|
- A failed or unavailable Discord replay delivery falls back to working locally hosted media.
|
||||||
|
- Commands remain operational when Discord is disabled or fails login.
|
||||||
|
- Web chat and Discord use the same configured prefix and whole-token matching behavior.
|
||||||
|
- Admin and lockdown permissions are enforced consistently from both transports.
|
||||||
|
- Disabled feature commands return a clear unavailable result, while enabled feature commands use their owning service's runtime safety checks.
|
||||||
|
- Hosted replay routes support playback and seeking, reject invalid paths, and cleanup only expired completed media.
|
||||||
|
|
||||||
|
Use direct inspection and practical command checks for ordinary response wording. Additional tests are appropriate when complex logic is extracted, but exhaustive output transcription is not a prerequisite for the refactor.
|
||||||
|
|
||||||
|
## Implementation sequence
|
||||||
|
|
||||||
|
Build directly toward the final architecture. It is acceptable to move commands in logical groups while working, but avoid investing in a durable old/new compatibility framework. Once a replacement path works, remove the obsolete adapter and duplicated implementation.
|
||||||
|
|
||||||
|
1. Add the operator command request, actor, result, parser, registry, authorization, and help foundations.
|
||||||
|
2. Extract existing Discord formatting and embed construction into transport-owned renderers without changing their output.
|
||||||
|
3. Move existing system and admin commands into the registry, using their current code as the behavioral reference.
|
||||||
|
4. Move status and time status while separating neutral data collection from unchanged Discord embed rendering.
|
||||||
|
5. Add organized registry-driven help with transport-specific output.
|
||||||
|
6. Add lift and Neato feature command families using the existing feature flags, services, and safety rules.
|
||||||
|
7. Add the automatic local replay media store, HTTP route, range serving, startup cleanup, periodic cleanup, and storage limits.
|
||||||
|
8. Split replay generation from delivery and add the Discord-preferred/local-fallback delivery coordinator.
|
||||||
|
9. Move replay onto the shared command service while preserving existing Discord progress and upload behavior.
|
||||||
|
10. Convert web chat and Discord to the shared command service and move bridge commands into the Discord-only extension registry.
|
||||||
|
11. Add Discord to the existing feature system and gate all Discord bootstrap and integrations through it.
|
||||||
|
12. Remove the Discord-owned shared router, fake Discord message objects, web replay command injection, result-flattening workaround, and duplicate replay paths.
|
||||||
|
13. Add or update focused tests for the high-risk boundaries listed above.
|
||||||
|
14. Run server tests, practical command checks, the web UI build, and targeted lint for touched files.
|
||||||
|
|
||||||
|
## Completion criteria
|
||||||
|
|
||||||
|
- The server has one transport-neutral command registry and execution path.
|
||||||
|
- Web chat commands work with Discord completely disabled.
|
||||||
|
- Discord consumes the shared command service as an optional adapter.
|
||||||
|
- The configured command prefix behaves consistently everywhere.
|
||||||
|
- Help is organized by System, Admin, Features, and Discord-only extensions where applicable.
|
||||||
|
- Detailed per-command and per-category help is available.
|
||||||
|
- Lift and Neato commands use existing service safety and availability behavior.
|
||||||
|
- Discord-hosted replays behave exactly as before when Discord delivery succeeds.
|
||||||
|
- Replays automatically fall back to maintained server-hosted media without configuration.
|
||||||
|
- Existing clients continue receiving compatible replay media payloads.
|
||||||
|
- Existing Discord embeds and outward behavior remain unchanged.
|
||||||
|
- Temporary adapters and duplicated command logic are removed.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# the inter-instance API and system
|
||||||
|
A single API endpoint that returns one json object with information about this instance of this server, meant to display on other servers.
|
||||||
|
A centralized json file pulled from a simple link on the internet which contains a list of public server instances
|
||||||
|
Basically, designed so that everyone's rover servers can show on everyone else's rover servers in some way.
|
||||||
|
In the end once its all working, users will be able to see rovers from other instances on any other instance, click on a rover, and just via a simple href with a few URL params, it will put you on that instance, that rover, and transfer your cookie object through a URL parameter.
|
||||||
|
|
||||||
|
## centralized json file of public instances
|
||||||
|
- contains a list of simple URLs, like:
|
||||||
|
```["https://rover.otter.land"], ["http://14.84.27.47:8080]```
|
||||||
|
- all servers will use the same link to the same json file by default (this will be to a file on github or something)
|
||||||
|
- there is an option for multiple links, for redundancy. but it only comes with one in the config.
|
||||||
|
- this should be ONLY a list of links, maybe with placeholder names to show in the UI if one of them is offline
|
||||||
|
- if my server had the two example links above, it would contact both info API endpoints from both of those separate instances for information about them.
|
||||||
|
- if a new server is to be added, add it to the centralized json file and that instance will show on all other instances, and it will show all other instances on itself.
|
||||||
|
|
||||||
|
## the general concept of the inter-instance API system
|
||||||
|
- every server hosts the same API endpoint which returns one big json object for that instance
|
||||||
|
- every server automatically gets the list of instances from the centralized json file
|
||||||
|
- every server automatically requests all of the other inter-instance information from all the other servers
|
||||||
|
- every server will show the info from all the other servers on it's web UI.
|
||||||
|
|
||||||
|
## what information will the servers get from the other servers?
|
||||||
|
- servers will get a bunch of info from the other servers which they poll the APIs of
|
||||||
|
- this information will, for the most part, just be sent straight to the web UI where most of the data moving will happen
|
||||||
|
- at least these things will need to be communicated
|
||||||
|
- is the server open? (turns/open access mode)
|
||||||
|
- server name
|
||||||
|
- server color for UI
|
||||||
|
- non-optional description
|
||||||
|
- an object of rovers containing, for each rover,
|
||||||
|
- rover name
|
||||||
|
- rover battery level
|
||||||
|
- any users on it?
|
||||||
|
- rover color
|
||||||
|
- rover description
|
||||||
|
- locked?
|
||||||
|
- locked reason
|
||||||
|
- basically, all the info that the webui uses now to show a rover in the rover roster
|
||||||
|
- maybe an object containing feature states, from the system of features.js in the server, so people can see what features that instance does and doesn't have
|
||||||
|
- MAYBE could even have images that are derived from that instance's URL that the web UI can use to show room cameras if they exist or rover snapshots
|
||||||
|
|
||||||
|
## what will this look like in the web UI?
|
||||||
|
- a button at the bottom of the rover roster that says show external rovers or something
|
||||||
|
- when you hit this button it shows the external rovers in the same roster stuff as the local instance rovres
|
||||||
|
- when this is expanded theres a button to open the shared inter-instance component in a popup
|
||||||
|
- a new component, a cardframe, which will be a component shared in multiple spots. contains:
|
||||||
|
- the instances
|
||||||
|
- the instance info, name, description, etc
|
||||||
|
- the rovers in the instances and their statuses
|
||||||
|
- the features that the instance has
|
||||||
|
- ALSO show this same cardframe on the admin lock overlay, so people can see other instances while their current one is locked
|
||||||
|
- all new UI has to be mobile friendly.
|
||||||
|
|
||||||
|
|
||||||
|
## switching to a different instance from a previous one
|
||||||
|
- users should be able to click on a rover from the listing of another instance, and be put on that rover on that instance.
|
||||||
|
- this should just be a thing that takes you to a new link to the new instance, with a couple of URL params.
|
||||||
|
- when switching, have a URL param for the rover that theyre requesting,
|
||||||
|
- this URL param should just make the web UI automatically request the rover from the param.
|
||||||
|
- and another URL param, which:
|
||||||
|
- takes their ENTIRE identity / settings cookie over to the new instance, by encoding the json in base64 in the URL.
|
||||||
|
- when the web UI takes this URL in, it should replace the cookie with the one from the URL. maybe with a popup first that asks to transfer your identity from previous instance to the new one?
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# ONVIF first, reolink specifics second PTZ camera integration
|
||||||
|
|
||||||
|
## what where who how
|
||||||
|
- adding support for a reolink PTZ camera
|
||||||
|
- ideally control everything over ONVIF
|
||||||
|
- if needed for some of the special features, use https://github.com/verheesj/reolink-api
|
||||||
|
- VIP (verified user) feature only
|
||||||
|
- due to upload bandwidth limitations (ONLY UPLOAD TO USERS MATTERS HERE NOT INTERNAl NETWORK STUFF), only one person should be on the camera at a time. only one person at a time should view
|
||||||
|
- the ptz camera should have a queue and turns that are like 5 mintues long or so, so no one can hog it
|
||||||
|
- if you are the camera operator, you are not on a rover. ever.
|
||||||
|
- if you are a spectator, you can see the snapshots for it
|
||||||
|
- local spectators should get full video like they already do now though
|
||||||
|
- the camera needs to be a replay source
|
||||||
|
- camera video needs to go through the same pipeline as rover video does and get to the client over webRTC
|
||||||
|
|
||||||
|
## camera learnings
|
||||||
|
- scan for all onvif features that the camera has
|
||||||
|
|
||||||
|
## UI flow:
|
||||||
|
- whole UI should be very technical and utilitarian
|
||||||
|
- use cardframe for everything
|
||||||
|
- match global styling
|
||||||
|
- new card in VIP tab
|
||||||
|
- shows whoevers on the camera
|
||||||
|
- a very slow snapshot of the camera view
|
||||||
|
- maybe some other stats
|
||||||
|
- a big button to open the camera controller
|
||||||
|
- the fullscreen camera interface
|
||||||
|
- the rest of the site needs to go away when this is open
|
||||||
|
- when its open, it takes over your rover controls. whatever they are
|
||||||
|
- easy route for this could be to intercept it right before the control goes to the server, so any control gets converted to a ptz control
|
||||||
|
- desktop
|
||||||
|
- movement controls pan and tilt
|
||||||
|
- camera up / down controls zoom
|
||||||
|
- headlight and laser buttons hopefully control spotlight and IR light or something
|
||||||
|
- fullscreen inteface
|
||||||
|
- right sidebar with info and controls info
|
||||||
|
- mobile
|
||||||
|
- uhhh idk
|
||||||
|
- obviously, camera on the screen
|
||||||
|
- probably add a variant of the mobile controls just to retitle the things from the rover controls to the camera controls
|
||||||
|
- and just use the same control columns
|
||||||
|
|
||||||
|
-- slop generated below --
|
||||||
|
|
||||||
|
## clarified implementation direction
|
||||||
|
|
||||||
|
This is not intended to become a generic ONVIF camera framework. The camera integration is for one specific Reolink PTZ camera. Once the camera arrives, we will run a one-time ONVIF capability discovery against that exact camera, record what it exposes, and then build the integration around those known capabilities.
|
||||||
|
|
||||||
|
The one-time discovery should capture:
|
||||||
|
- ONVIF services exposed by the camera
|
||||||
|
- media profiles and stream URIs
|
||||||
|
- snapshot URI support
|
||||||
|
- PTZ support and movement modes
|
||||||
|
- pan/tilt/zoom ranges and speed ranges
|
||||||
|
- preset/home support
|
||||||
|
- imaging controls
|
||||||
|
- any ONVIF-exposed spotlight, IR, or night-vision controls
|
||||||
|
- whether PTZ status reporting is reliable
|
||||||
|
|
||||||
|
After that, runtime code should assume this known camera profile instead of trying to dynamically support every possible ONVIF camera.
|
||||||
|
|
||||||
|
## claiming and operator rules
|
||||||
|
|
||||||
|
The PTZ camera is a single scarce controllable resource.
|
||||||
|
|
||||||
|
Only verified/VIP users can claim it during normal operation. Only one user can operate it at a time. The active operator gets live WebRTC video and PTZ control for a limited turn, probably around five minutes. Other remote users should only receive slow snapshots. Local spectators may be allowed live video because LAN traffic is not the bandwidth problem.
|
||||||
|
|
||||||
|
A user operating the PTZ camera must not also be operating a rover. When a user tries to move from a rover to PTZ, the existing rover-switch safety rule should be reused: switching is allowed if another driver remains on that rover, or if the current rover is docked and charging. Otherwise, the server should block the PTZ handoff and tell the user to dock and charge their rover first.
|
||||||
|
|
||||||
|
This should be implemented by refactoring the existing rover switching check into a shared helper, such as `canLeaveCurrentRover(socket)`, then using that helper from both rover switching and PTZ claiming.
|
||||||
|
|
||||||
|
## streaming model
|
||||||
|
|
||||||
|
Camera video should come from the Reolink camera over the local network, likely RTSP into MediaMTX. Browser playback should use the existing MediaMTX WHEP/WebRTC pipeline.
|
||||||
|
|
||||||
|
The existing video session and MediaMTX auth system should be extended with a `ptz` source type. Remote live WHEP access should be allowed for the current PTZ operator, local spectators, and authorized admins according to normal server rules. Remote non-operators should not get live video.
|
||||||
|
|
||||||
|
Slow snapshots should use a PTZ-specific snapshot path or socket gateway, modeled after the existing room camera snapshot system, but with PTZ-specific authorization rules.
|
||||||
|
|
||||||
|
## lockdown behavior
|
||||||
|
|
||||||
|
No extra UI work is needed for lockdown because the app already visually blocks things in lockdown mode.
|
||||||
|
|
||||||
|
Server-side lockdown enforcement is still required everywhere. In lockdown mode, only lockdown admins/users may claim, queue, operate, subscribe to snapshots, request live PTZ video, or use PTZ replay sources. If lockdown starts while a normal user is operating PTZ, the server should immediately revoke their operator state, remove them from the PTZ queue if needed, revoke PTZ video sessions, and stop accepting PTZ commands from them.
|
||||||
|
|
||||||
|
## reusable existing systems
|
||||||
|
|
||||||
|
Strong reuse targets:
|
||||||
|
- rover switch safety logic from `roverManager/roverLifecycle.js`
|
||||||
|
- `videoSessions`
|
||||||
|
- `videoSocketService`
|
||||||
|
- `videoAuthService`
|
||||||
|
- `WhepPlayer`
|
||||||
|
- `sessionService` session sync
|
||||||
|
- VIP panel/card structure
|
||||||
|
- alert system
|
||||||
|
- replay source validation and replay worker architecture
|
||||||
|
|
||||||
|
Adapted reuse targets:
|
||||||
|
- turn queue/timer structure from `turnService`
|
||||||
|
- turn alert listener behavior
|
||||||
|
- room camera snapshot socket/feed pattern
|
||||||
|
- `RoomCameraFeed` for slow preview display
|
||||||
|
- replay source catalog and ffmpeg workers
|
||||||
|
- existing control input concepts, but with a PTZ-specific command pipeline
|
||||||
|
|
||||||
|
Do not directly merge PTZ into `roomCameraService` or `commandService`. PTZ should have its own service boundary because it has ownership, queueing, ONVIF control, video authorization, and camera-specific state.
|
||||||
|
|
||||||
|
## operator UI and controls
|
||||||
|
|
||||||
|
The VIP tab should get a PTZ camera card. The card should be technical/utilitarian and match the existing site style. It should show:
|
||||||
|
- current camera operator
|
||||||
|
- queue/turn state
|
||||||
|
- turn time remaining when relevant
|
||||||
|
- whether the current user can claim or must wait
|
||||||
|
- whether the current user must dock and charge before switching
|
||||||
|
- a slow snapshot preview
|
||||||
|
- a button to open the fullscreen PTZ controller when the user is the active operator
|
||||||
|
|
||||||
|
The fullscreen PTZ controller should take over the whole app surface while open. It should not feel like a normal side panel. When active, the user is in camera-operation mode, not rover-driving mode.
|
||||||
|
|
||||||
|
Desktop controls:
|
||||||
|
- movement input pans and tilts the camera
|
||||||
|
- camera up/down or equivalent camera tilt controls zoom in/out
|
||||||
|
- available special controls expose only what the one-time ONVIF probe proved exists
|
||||||
|
- if ONVIF exposes presets/home, provide those controls
|
||||||
|
- if ONVIF exposes spotlight, IR, or night mode, provide those controls
|
||||||
|
- if those features are not exposed through ONVIF, leave them out until a Reolink-specific fallback is intentionally added
|
||||||
|
- include a compact right-side status/control panel with operator, queue, camera state, and available controls
|
||||||
|
|
||||||
|
Mobile controls:
|
||||||
|
- reuse the existing mobile control layout concept where practical
|
||||||
|
- relabel/re-map rover movement controls for PTZ movement
|
||||||
|
- keep the camera view as the main screen
|
||||||
|
- use the existing mobile control columns/pads as inspiration, but send PTZ commands instead of rover commands
|
||||||
|
|
||||||
|
Input/control implementation:
|
||||||
|
- do not send PTZ through the existing rover `commandService`
|
||||||
|
- create PTZ-specific socket events/handlers owned by the PTZ camera service
|
||||||
|
- use a PTZ-specific client command pipeline that maps existing input intent into PTZ commands
|
||||||
|
- server must enforce that only the active PTZ operator can send movement/zoom/control commands
|
||||||
|
- client-side input interception is only for UX; server-side operator checks are the real authority
|
||||||
|
- all movement controls should send stop commands on key/button release, blur, disconnect, controller close, or turn loss
|
||||||
|
|
||||||
|
## NEW UI STUFF
|
||||||
|
- desktop:
|
||||||
|
- sidebar like there is now
|
||||||
|
- replay panel in sidebar
|
||||||
|
- better indicators of light and OR modes
|
||||||
|
- list of controls using keybind things
|
||||||
|
- mobile:
|
||||||
|
- one sidebar on the right
|
||||||
|
- reuse rover drive control panel for camera movement
|
||||||
|
- reuse gpio toggle buttons for spotlight and IR
|
||||||
|
- reuse camera tilt slider for zoom
|
||||||
|
- relabeled variants where needed for reused mobile controls
|
||||||
|
- scroll sidebar down to see replay panel
|
||||||
|
- both:
|
||||||
|
- the VIP panel
|
||||||
|
- sucks.
|
||||||
|
- wasted space
|
||||||
|
- put snapshot and everything else side by side
|
||||||
|
- add a display of ptz's turn queue
|
||||||
|
- camera should open when you request control over it. no need to have it be another button to press
|
||||||
|
- should show state of camera
|
||||||
|
- the fullscreen interface
|
||||||
|
- should be inside a cardframe, with no title bar
|
||||||
|
- reuse anything whereever possible
|
||||||
|
- needs to be ACTUALLY FULLSCREEN, not with space around the edges anywhere
|
||||||
|
- needs to match the global styling, and use cardframes internally for stuff.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
- make ptz camera better integrated
|
||||||
|
- keep current fullscreen interface, its good.
|
||||||
|
- but remove the card from the vip panel
|
||||||
|
- clean up the fullscreen interface to match the rest of the page better
|
||||||
|
- have a clear close button
|
||||||
|
- on desktop, have some stuff in sidebar and some stuff below the video
|
||||||
|
- video should keep the rest of the space
|
||||||
|
- reuse rover HUD elements like chat input and not your turn indicator
|
||||||
|
- probably make it so that the rest of the page unmounts or unloads or whatever when youre in it
|
||||||
|
- make it feel more like youre switching to a different rover instead of switching to a completely different thing
|
||||||
|
- simplify and reuse components wherever possible, frontend and backend
|
||||||
|
- right now, it feels tacked on, badly integrated, and incomplete
|
||||||
|
- needs a much better UI flow
|
||||||
|
- still needs to be a VIP feature
|
||||||
|
- for users on ptz, make their chats have a rover badge that has the ptz name and a color
|
||||||
|
- make the cam show up as a room camera in the room camera panel
|
||||||
|
- snapshot mode only
|
||||||
|
- make the ptz queue and join button show up as one roverqueuespanel style rover row below the links panel
|
||||||
|
- only show it open for verified users, for non-verified users overlay it with a message and dont let them click on it
|
||||||
|
- for mobile layouts, show it below the roverqueuepanel.
|
||||||
|
- dont worry about not being invasive, just dont break anything
|
||||||
|
|
||||||
|
## ui flow should be:
|
||||||
|
1. you are verified
|
||||||
|
2. you see the ptz camera queue in the ui, it has 2 people in it
|
||||||
|
3. you click on it, the fullscreen UI opens
|
||||||
|
- other people will see you in the queue in the little panel
|
||||||
|
4. its not your turn yet. the fullscreen UI replaces the page.
|
||||||
|
- you see snapshots, you see the "not your turn" hud, same as driving a rover
|
||||||
|
5. its now your turn. the overlay shows up just as it does in rover hud
|
||||||
|
6. you control the camera like usual, you want to close it
|
||||||
|
7. you hit the close button, you get removed from the queue
|
||||||
|
8. the page returns to normal
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# why?
|
||||||
|
for anyone to be able to run a server, without all the specialty random interactive hardware.
|
||||||
|
|
||||||
|
## what?
|
||||||
|
- make it so the entire server and web UI can work with ONLY ROVERS and nothing else
|
||||||
|
- make sure that any extra feature can be disabled server-side, and when disabled it disappears from the web UI without a trace. no empty panels that say "nothing configured"
|
||||||
|
- ONLY mess with features that require extra hardware.
|
||||||
|
- make all extra integrations that arent only software be disabled on install, so if you want to add support for one you enable it manually.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# make all bandwidth saving options toggleable in one centralized server config
|
||||||
|
- external spectators are people outside of local network
|
||||||
|
|
||||||
|
- multitab protection mode
|
||||||
|
- allowed
|
||||||
|
- verified only
|
||||||
|
- not allowed
|
||||||
|
- snapshots
|
||||||
|
- non-turn video
|
||||||
|
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots after the user threshold is exceeded)
|
||||||
|
- live (rover non-active turn holders and PTZ non-operators can get full video)
|
||||||
|
- userThreshold (snapshots turn on when controllable users exceed this number)
|
||||||
|
- external spectator video
|
||||||
|
- snapshots (external spectators are only allowed snapshots)
|
||||||
|
- live (external spectators can get full video)
|
||||||
|
- external spectator access (new)
|
||||||
|
- off (no one can access the spectate page externally)
|
||||||
|
- on (everyone can access the spectate page externally)
|
||||||
|
- verifiedOnly (only verified identities can access the spectate page externally)
|
||||||
|
- admin (external spectators need a saved spectatorAccess.external identity grant)
|
||||||
|
- anything else related to bandwidth savings should also get config
|
||||||
|
|
||||||
|
## implemented config shape
|
||||||
|
```yaml
|
||||||
|
bandwidthSavings:
|
||||||
|
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
|
||||||
|
nonTurnVideo:
|
||||||
|
mode: "snapshots" # snapshots | live
|
||||||
|
userThreshold: 0 # snapshots turn on when controllable users exceed this number
|
||||||
|
externalSpectatorVideo: "snapshots" # snapshots | live
|
||||||
|
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
|
||||||
|
```
|
||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Chrome Google TTS WAV renderer.
|
||||||
|
|
||||||
|
Purpose: Converts the same local ChromeOS Google TTS assets used by rovers into
|
||||||
|
server-side WAV files that can be handed to another playback transport.
|
||||||
|
Scope: This script only renders one utterance to a file; device playback and
|
||||||
|
camera delivery stay owned by Node services.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_ROOT = "/opt/roverd/googletts"
|
||||||
|
LIB_PATH = os.path.join(ASSET_ROOT, "libchrometts.so")
|
||||||
|
VOICE_DIR = os.path.join(ASSET_ROOT, "en-us-x-multi-r30")
|
||||||
|
PIPELINE = "pipeline.pb"
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
MAX_TEXT_CHARS = 512
|
||||||
|
|
||||||
|
VOICES = {
|
||||||
|
"sfg": "female",
|
||||||
|
"iob": "female",
|
||||||
|
"iog": "female",
|
||||||
|
"iol": "male",
|
||||||
|
"iom": "male",
|
||||||
|
"tpc": "female",
|
||||||
|
"tpd": "male",
|
||||||
|
"tpf": "female",
|
||||||
|
}
|
||||||
|
DEFAULT_VOICE = "tpf"
|
||||||
|
DEFAULT_PITCH = 1.0
|
||||||
|
DEFAULT_SPEED = 1.0
|
||||||
|
MIN_PITCH = 0.5
|
||||||
|
MAX_PITCH = 2.0
|
||||||
|
MIN_SPEED = 0.5
|
||||||
|
MAX_SPEED = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def varint(value):
|
||||||
|
out = bytearray()
|
||||||
|
while value >= 0x80:
|
||||||
|
out.append((value & 0x7F) | 0x80)
|
||||||
|
value >>= 7
|
||||||
|
out.append(value)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def field_bytes(number, payload):
|
||||||
|
return varint((number << 3) | 2) + varint(len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def field_float(number, value):
|
||||||
|
return varint((number << 3) | 5) + struct.pack("<f", float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def build_utterance(text, pitch=1.0, speed=1.0):
|
||||||
|
params = field_float(2, pitch) + field_float(3, speed)
|
||||||
|
msg_b = field_bytes(1, text.encode("utf-8")) + field_bytes(20, params)
|
||||||
|
msg_a = field_bytes(1, msg_b)
|
||||||
|
return field_bytes(1, msg_a)
|
||||||
|
|
||||||
|
|
||||||
|
def build_speaker(name, gender):
|
||||||
|
return field_bytes(1, name.encode("utf-8")) + field_bytes(2, gender.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_float(value, minimum, maximum, fallback):
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
if value <= 0:
|
||||||
|
return fallback
|
||||||
|
if value < minimum:
|
||||||
|
return minimum
|
||||||
|
if value > maximum:
|
||||||
|
return maximum
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def float_to_s16le(samples):
|
||||||
|
pcm = bytearray()
|
||||||
|
for sample in samples:
|
||||||
|
clipped = max(-1.0, min(1.0, float(sample)))
|
||||||
|
pcm.extend(struct.pack("<h", int(clipped * 32767)))
|
||||||
|
return bytes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
class ChromeTTS:
|
||||||
|
def __init__(self):
|
||||||
|
self.lib = ctypes.CDLL(LIB_PATH)
|
||||||
|
self.lib.GoogleTtsInit.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||||
|
self.lib.GoogleTtsInit.restype = ctypes.c_bool
|
||||||
|
self.lib.GoogleTtsInitBuffered.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||||
|
self.lib.GoogleTtsInitBuffered.restype = ctypes.c_bool
|
||||||
|
self.lib.GoogleTtsGetFramesInAudioBuffer.argtypes = []
|
||||||
|
self.lib.GoogleTtsGetFramesInAudioBuffer.restype = ctypes.c_size_t
|
||||||
|
self.lib.GoogleTtsReadBuffered.argtypes = [
|
||||||
|
ctypes.POINTER(ctypes.c_float),
|
||||||
|
ctypes.POINTER(ctypes.c_size_t),
|
||||||
|
]
|
||||||
|
self.lib.GoogleTtsReadBuffered.restype = ctypes.c_int
|
||||||
|
self.lib.GoogleTtsShutdown.argtypes = []
|
||||||
|
self.lib.GoogleTtsShutdown.restype = None
|
||||||
|
|
||||||
|
voice_dir = os.path.abspath(VOICE_DIR) + os.sep
|
||||||
|
pipeline = os.path.join(voice_dir, PIPELINE)
|
||||||
|
if not self.lib.GoogleTtsInit(pipeline.encode("utf-8"), voice_dir.encode("utf-8")):
|
||||||
|
raise RuntimeError("GoogleTtsInit failed")
|
||||||
|
self.frames = int(self.lib.GoogleTtsGetFramesInAudioBuffer())
|
||||||
|
if self.frames <= 0:
|
||||||
|
raise RuntimeError("invalid Google TTS audio buffer size")
|
||||||
|
self.buffer = (ctypes.c_float * self.frames)()
|
||||||
|
|
||||||
|
def render_wav(self, text, output_path, voice, pitch=DEFAULT_PITCH, speed=DEFAULT_SPEED):
|
||||||
|
voice = voice if voice in VOICES else DEFAULT_VOICE
|
||||||
|
pitch = clamp_float(pitch, MIN_PITCH, MAX_PITCH, DEFAULT_PITCH)
|
||||||
|
speed = clamp_float(speed, MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("text required")
|
||||||
|
text = text[:MAX_TEXT_CHARS]
|
||||||
|
|
||||||
|
utterance = build_utterance(text, pitch=pitch, speed=speed)
|
||||||
|
speaker = build_speaker(voice, VOICES[voice])
|
||||||
|
if not self.lib.GoogleTtsInitBuffered(utterance, speaker, len(utterance), len(speaker)):
|
||||||
|
raise RuntimeError("GoogleTtsInitBuffered failed")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||||
|
with wave.open(output_path, "wb") as wav:
|
||||||
|
wav.setnchannels(1)
|
||||||
|
wav.setsampwidth(2)
|
||||||
|
wav.setframerate(SAMPLE_RATE)
|
||||||
|
frames_written = ctypes.c_size_t(0)
|
||||||
|
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
|
||||||
|
count = int(frames_written.value)
|
||||||
|
if count > 0:
|
||||||
|
wav.writeframes(float_to_s16le(self.buffer[:count]))
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.lib.GoogleTtsShutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Render Chrome Google TTS to a WAV file.")
|
||||||
|
parser.add_argument("--text", required=True)
|
||||||
|
parser.add_argument("--voice", default=DEFAULT_VOICE)
|
||||||
|
parser.add_argument("--pitch", type=float, default=DEFAULT_PITCH)
|
||||||
|
parser.add_argument("--speed", type=float, default=DEFAULT_SPEED)
|
||||||
|
parser.add_argument("--output", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
tts = ChromeTTS()
|
||||||
|
try:
|
||||||
|
tts.render_wav(args.text, args.output, args.voice, args.pitch, args.speed)
|
||||||
|
finally:
|
||||||
|
tts.shutdown()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except Exception as exc:
|
||||||
|
sys.stderr.write(f"chromegtts-wav failed: {exc}\n")
|
||||||
|
raise SystemExit(1)
|
||||||
+89
-11
@@ -7,12 +7,27 @@ admins:
|
|||||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||||
discord_id: "0987654321"
|
discord_id: "0987654321"
|
||||||
lockdown: true
|
lockdown: true
|
||||||
|
|
||||||
timezone: "America/New_York"
|
timezone: "America/New_York"
|
||||||
|
|
||||||
|
interInstance:
|
||||||
|
enabled: false
|
||||||
|
directoryUrls:
|
||||||
|
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
|
||||||
|
pollIntervalMs: 30000
|
||||||
|
requestTimeoutMs: 5000
|
||||||
|
profile:
|
||||||
|
publicUrl: "https://rover.example.com"
|
||||||
|
name: "Example Rover Server"
|
||||||
|
description: "A short public description of this rover server."
|
||||||
|
color: "#38bdf8"
|
||||||
|
|
||||||
llmCommentary:
|
llmCommentary:
|
||||||
enabled: false
|
enabled: false
|
||||||
model: "qwen2.5:7b-instruct"
|
model: "qwen2.5:7b-instruct"
|
||||||
ollamaServer: "http://127.0.0.1:11434"
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
frequency: 120000
|
frequency: 120000
|
||||||
|
|
||||||
overseerControl:
|
overseerControl:
|
||||||
enabled: false
|
enabled: false
|
||||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||||
@@ -27,15 +42,44 @@ overseerControl:
|
|||||||
ollamaServer: "http://127.0.0.1:11434"
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
profileImageUrl: "https://example.com/overseer.png"
|
profileImageUrl: "https://example.com/overseer.png"
|
||||||
gateIntervalMs: 2000
|
gateIntervalMs: 2000
|
||||||
|
|
||||||
barcodeGames:
|
barcodeGames:
|
||||||
|
enabled: false
|
||||||
botName: "Barcode Games"
|
botName: "Barcode Games"
|
||||||
profileImageUrl: "https://example.com/barcode-games.png"
|
profileImageUrl: "https://example.com/barcode-games.png"
|
||||||
|
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
# Example: http://192.168.0.86:8889/video
|
# Example: http://192.168.0.86:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
|
|
||||||
|
bandwidthSavings:
|
||||||
|
# Duplicate driver-tab handling for the same browser identity.
|
||||||
|
# allowed: no duplicate-tab protection
|
||||||
|
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||||
|
# notAllowed: every identity is limited to one driver tab
|
||||||
|
multiTabProtection: "verifiedOnly"
|
||||||
|
# Video for users who are attached to a source but do not currently own its
|
||||||
|
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||||
|
# whenever the normal mode/visibility rules allow it.
|
||||||
|
nonTurnVideo:
|
||||||
|
mode: "snapshots"
|
||||||
|
# Snapshot mode activates only when controllable users exceed this number.
|
||||||
|
# A controllable user is attached to a rover or PTZ as operator/queue, not a
|
||||||
|
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
|
||||||
|
# actually attached to a controllable source.
|
||||||
|
userThreshold: 0
|
||||||
|
# Live video for spectators outside the local network. Local spectators are
|
||||||
|
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||||
|
externalSpectatorVideo: "snapshots"
|
||||||
|
# Whether non-local users may enter the spectator page.
|
||||||
|
# off: block external spectators
|
||||||
|
# on: allow external spectators
|
||||||
|
# verifiedOnly: require a verified identity, but no separate spectator grant
|
||||||
|
# admin: require an identity feature-state grant at spectatorAccess.external
|
||||||
|
externalSpectatorAccess: "on"
|
||||||
|
|
||||||
audioForward:
|
audioForward:
|
||||||
enabled: true
|
enabled: true
|
||||||
ffmpegBin: "ffmpeg"
|
ffmpegBin: "ffmpeg"
|
||||||
@@ -49,13 +93,16 @@ audioLevels:
|
|||||||
forwardGain: 1.0
|
forwardGain: 1.0
|
||||||
|
|
||||||
homeAssistant:
|
homeAssistant:
|
||||||
|
enabled: false
|
||||||
url: "http://homeassistant.local:8123"
|
url: "http://homeassistant.local:8123"
|
||||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||||
neato:
|
neato:
|
||||||
|
enabled: false
|
||||||
# ESPHome device name, used to derive gen3 entities:
|
# ESPHome device name, used to derive gen3 entities:
|
||||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||||
device: "neato_vacuum"
|
device: "neato_vacuum"
|
||||||
lift:
|
lift:
|
||||||
|
enabled: false
|
||||||
# Two Home Assistant switches controlling lift direction.
|
# Two Home Assistant switches controlling lift direction.
|
||||||
# Raise sequence: down off -> wait interlockMs -> up on
|
# Raise sequence: down off -> wait interlockMs -> up on
|
||||||
# Lower sequence: up off -> wait interlockMs -> down on
|
# Lower sequence: up off -> wait interlockMs -> down on
|
||||||
@@ -92,7 +139,10 @@ homeAssistant:
|
|||||||
stateEquals: "toggle"
|
stateEquals: "toggle"
|
||||||
cooldownMs: 1000
|
cooldownMs: 1000
|
||||||
action: "lightsLockToggle"
|
action: "lightsLockToggle"
|
||||||
|
|
||||||
roomCameras:
|
roomCameras:
|
||||||
|
enabled: false
|
||||||
|
cameras:
|
||||||
- id: "lobby"
|
- id: "lobby"
|
||||||
name: "Lobby Camera"
|
name: "Lobby Camera"
|
||||||
description: "Wide shot of the staging area."
|
description: "Wide shot of the staging area."
|
||||||
@@ -104,6 +154,22 @@ roomCameras:
|
|||||||
url: "http://192.168.0.51/snapshot.jpg"
|
url: "http://192.168.0.51/snapshot.jpg"
|
||||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||||
|
|
||||||
|
ptzCamera:
|
||||||
|
enabled: false
|
||||||
|
name: "PTZ Camera"
|
||||||
|
host: "192.168.0.8"
|
||||||
|
onvifPort: 8000
|
||||||
|
username: "admin"
|
||||||
|
password: "REPLACE_WITH_CAMERA_PASSWORD"
|
||||||
|
# The Reolink TrackMix autotrack profile was token 003 during commissioning.
|
||||||
|
# Keeping this configurable lets firmware/profile resets be fixed without code
|
||||||
|
# changes while the integration still remains a single-camera feature.
|
||||||
|
profileToken: "003"
|
||||||
|
turnDurationMs: 300000
|
||||||
|
# PTZ replay capture needs a known-good replay encoder on the server. Keep it
|
||||||
|
# off by default so adding live PTZ does not start a broken replay worker loop.
|
||||||
|
replayEnabled: false
|
||||||
|
|
||||||
kinect:
|
kinect:
|
||||||
enabled: false
|
enabled: false
|
||||||
# Capture requests are global across 3d/color so one person cannot spam room
|
# Capture requests are global across 3d/color so one person cannot spam room
|
||||||
@@ -111,7 +177,27 @@ kinect:
|
|||||||
# camera cache; it only gates browser-requested broadcasts.
|
# camera cache; it only gates browser-requested broadcasts.
|
||||||
captureCooldownMs: 10000
|
captureCooldownMs: 10000
|
||||||
|
|
||||||
|
balanceBoard:
|
||||||
|
# The server installer always prepares Bluetooth and the kernel driver. This
|
||||||
|
# switch only starts the service and shows its small live-weight panel.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
buttonBox:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
barcodeScanner:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
commands:
|
||||||
|
# Commands are a core server capability shared by site chat and optional
|
||||||
|
# transports. Their names therefore do not belong to Discord configuration.
|
||||||
|
prefix: "rs"
|
||||||
|
# Set this to null to disable the legacy bare time-status shortcut.
|
||||||
|
timeStatusCommand: "ts"
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
|
# Discord is optional. A token by itself never enables an external login.
|
||||||
|
enabled: false
|
||||||
token: "DISCORD_BOT_TOKEN"
|
token: "DISCORD_BOT_TOKEN"
|
||||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||||
siteUrl: "https://rover.example.com"
|
siteUrl: "https://rover.example.com"
|
||||||
@@ -119,7 +205,7 @@ discord:
|
|||||||
general: "123456789012345678"
|
general: "123456789012345678"
|
||||||
announcements: "123456789012345678"
|
announcements: "123456789012345678"
|
||||||
adminAlerts: "123456789012345678"
|
adminAlerts: "123456789012345678"
|
||||||
# chat bridge is configured per guild via `rs bridge` commands
|
# chat bridge is configured per guild via the shared `commands.prefix`
|
||||||
replay: "123456789012345678"
|
replay: "123456789012345678"
|
||||||
humanAlerts: "123456789012345678"
|
humanAlerts: "123456789012345678"
|
||||||
roles:
|
roles:
|
||||||
@@ -129,6 +215,8 @@ discord:
|
|||||||
humanAlertPing: "123456789012345678"
|
humanAlertPing: "123456789012345678"
|
||||||
|
|
||||||
socials:
|
socials:
|
||||||
|
enabled: false
|
||||||
|
links:
|
||||||
- id: "discord"
|
- id: "discord"
|
||||||
label: "Discord"
|
label: "Discord"
|
||||||
url: "https://discord.gg/your-invite"
|
url: "https://discord.gg/your-invite"
|
||||||
@@ -139,13 +227,3 @@ socials:
|
|||||||
url: "https://ko-fi.com/your-handle"
|
url: "https://ko-fi.com/your-handle"
|
||||||
icon: "FaCoffee"
|
icon: "FaCoffee"
|
||||||
color: "#29ABE0"
|
color: "#29ABE0"
|
||||||
- id: "wiki"
|
|
||||||
label: "Wiki"
|
|
||||||
url: "https://wiki.example.com"
|
|
||||||
icon: "FaBook"
|
|
||||||
color: "#475569"
|
|
||||||
- id: "throne"
|
|
||||||
label: "Throne"
|
|
||||||
url: "https://throne.me/yourname"
|
|
||||||
icon: "FaCrown"
|
|
||||||
color: "#334155"
|
|
||||||
|
|||||||
@@ -62,6 +62,12 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"entityId": "brick",
|
"entityId": "brick",
|
||||||
"label": "BRICK"
|
"label": "BRICK"
|
||||||
|
},
|
||||||
|
"o009": {
|
||||||
|
"type": "object",
|
||||||
|
"entityId": "gbc",
|
||||||
|
"label": "Green Ball Container",
|
||||||
|
"wikiUrl": "https://wiki.otter.land/Room%20Objects/Green%20Ball%20Container"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ require('./src/services/overseerControlService');
|
|||||||
require('./src/services/globalObjectiveService');
|
require('./src/services/globalObjectiveService');
|
||||||
require('./src/services/serverControlService');
|
require('./src/services/serverControlService');
|
||||||
require('./src/services/videoSessions');
|
require('./src/services/videoSessions');
|
||||||
|
require('./src/services/ptzCameraService');
|
||||||
require('./src/services/videoAuthService');
|
require('./src/services/videoAuthService');
|
||||||
require('./src/services/videoSocketService');
|
require('./src/services/videoSocketService');
|
||||||
require('./src/services/roomCameraService');
|
require('./src/services/roomCameraService');
|
||||||
require('./src/services/roverSnapshotService');
|
require('./src/services/roverSnapshotService');
|
||||||
|
require('./src/services/interInstanceService');
|
||||||
require('./src/services/humanAlertButtonService');
|
require('./src/services/humanAlertButtonService');
|
||||||
require('./src/services/embedHttpService');
|
require('./src/services/embedHttpService');
|
||||||
require('./src/services/logStreamService');
|
require('./src/services/logStreamService');
|
||||||
@@ -44,8 +46,12 @@ require('./src/services/buttonBoxService');
|
|||||||
require('./src/services/barcodeScannerService');
|
require('./src/services/barcodeScannerService');
|
||||||
require('./src/services/barcodeGameService');
|
require('./src/services/barcodeGameService');
|
||||||
require('./src/services/kinectService');
|
require('./src/services/kinectService');
|
||||||
|
require('./src/services/balanceBoardService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
require('./src/services/replayEngineV2');
|
require('./src/services/replayEngineV2');
|
||||||
|
// Replay delivery is a core service. It must subscribe before the optional
|
||||||
|
// Discord feature so web requests always have a local delivery path.
|
||||||
|
require('./src/services/replayDeliveryService');
|
||||||
require('./src/services/discordBotService');
|
require('./src/services/discordBotService');
|
||||||
require('./src/services/httpServer');
|
require('./src/services/httpServer');
|
||||||
|
|||||||
+169
-6
@@ -2,8 +2,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
MEDIAMTX_VERSION="1.15.3"
|
MEDIAMTX_VERSION="1.15.3"
|
||||||
|
NEOLINK_VERSION="0.6.2"
|
||||||
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
||||||
|
NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}"
|
||||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||||
|
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||||
|
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||||
@@ -12,6 +16,8 @@ MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
|||||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||||
|
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
|
||||||
|
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This installer must be run with sudo/root." >&2
|
echo "This installer must be run with sudo/root." >&2
|
||||||
@@ -26,9 +32,84 @@ fi
|
|||||||
TARGET_USER="$SUDO_USER"
|
TARGET_USER="$SUDO_USER"
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
SERVER_DIR="$SCRIPT_DIR"
|
SERVER_DIR="$SCRIPT_DIR"
|
||||||
|
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
|
||||||
|
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
|
||||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||||
|
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
||||||
|
|
||||||
|
install_google_tts_assets() {
|
||||||
|
local asset_dir="/opt/roverd/googletts"
|
||||||
|
local voice_dir="${asset_dir}/en-us-x-multi-r30"
|
||||||
|
local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz"
|
||||||
|
local lib_member=""
|
||||||
|
local arch_name
|
||||||
|
arch_name=$(uname -m)
|
||||||
|
|
||||||
|
# The PTZ camera is not a rover, so Google speech must be synthesized on the
|
||||||
|
# server before neolink sends a WAV to the camera. These assets are the same
|
||||||
|
# offline ChromeOS local TTS assets that rover installers already use; keeping
|
||||||
|
# the layout identical lets the server helper and rover daemon share loader
|
||||||
|
# assumptions.
|
||||||
|
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
|
||||||
|
echo " Google TTS assets already installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$arch_name" in
|
||||||
|
x86_64|amd64)
|
||||||
|
lib_member="libchrometts_x86_64.so"
|
||||||
|
;;
|
||||||
|
aarch64)
|
||||||
|
lib_member="libchrometts_arm64.so"
|
||||||
|
;;
|
||||||
|
armv7l|armv6l)
|
||||||
|
lib_member="libchrometts_armv7.so"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported Google TTS architecture: $arch_name" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo " Installing Google TTS assets -> $asset_dir"
|
||||||
|
curl -L -o "$tmpdir/googletts-26.5.tar.xz" "$dist_url"
|
||||||
|
tar -xf "$tmpdir/googletts-26.5.tar.xz" -C "$tmpdir" en-us-x-multi.zvoice "$lib_member"
|
||||||
|
install -d -o root -g root -m 0755 "$asset_dir"
|
||||||
|
install -o root -g root -m 0644 "$tmpdir/$lib_member" "${asset_dir}/libchrometts.so"
|
||||||
|
rm -rf "$voice_dir"
|
||||||
|
install -d -o root -g root -m 0755 "$voice_dir"
|
||||||
|
# The .zvoice member is a zip archive inside the outer tar.xz. Match the
|
||||||
|
# rover installers here; trying to untar it fails after the large download.
|
||||||
|
unzip -q "$tmpdir/en-us-x-multi.zvoice" -d "$voice_dir"
|
||||||
|
chown -R root:root "$asset_dir"
|
||||||
|
find "$asset_dir" -type d -exec chmod 0755 {} +
|
||||||
|
find "$asset_dir" -type f -exec chmod 0644 {} +
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_google_tts_helper() {
|
||||||
|
local smoke_wav="$tmpdir/chromegtts-smoke.wav"
|
||||||
|
|
||||||
|
echo " Verifying Chrome Google TTS helper"
|
||||||
|
# libchrometts is a native ChromeOS library. Rendering one tiny WAV during
|
||||||
|
# install catches missing shared-library dependencies, bad asset extraction,
|
||||||
|
# and helper path mistakes before multirover.service starts accepting PTZ TTS
|
||||||
|
# requests that would fail later in logs.
|
||||||
|
if ! "$CHROMEGTTS_WAV_BIN" \
|
||||||
|
--text "test" \
|
||||||
|
--voice tpf \
|
||||||
|
--pitch 1 \
|
||||||
|
--speed 1 \
|
||||||
|
--output "$smoke_wav"; then
|
||||||
|
echo "Chrome Google TTS helper smoke render failed." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$smoke_wav" ]]; then
|
||||||
|
echo "Chrome Google TTS helper did not create a WAV file." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo "[1/6] Installing dependencies..."
|
echo "[1/6] Installing dependencies..."
|
||||||
# The Kinect tooling uses a native libfreenect worker/probe rather than a
|
# The Kinect tooling uses a native libfreenect worker/probe rather than a
|
||||||
@@ -40,12 +121,28 @@ dnf install -y \
|
|||||||
npm \
|
npm \
|
||||||
curl \
|
curl \
|
||||||
tar \
|
tar \
|
||||||
|
unzip \
|
||||||
|
xz \
|
||||||
gcc-c++ \
|
gcc-c++ \
|
||||||
make \
|
make \
|
||||||
pkgconf-pkg-config \
|
pkgconf-pkg-config \
|
||||||
|
flite \
|
||||||
|
espeak \
|
||||||
|
python3 \
|
||||||
|
libcxx \
|
||||||
|
libcxxabi \
|
||||||
|
gstreamer1 \
|
||||||
|
gstreamer1-plugins-base \
|
||||||
|
gstreamer1-plugins-good \
|
||||||
|
gstreamer1-plugins-bad-free \
|
||||||
|
gstreamer1-rtsp-server \
|
||||||
libfreenect \
|
libfreenect \
|
||||||
libfreenect-devel \
|
libfreenect-devel \
|
||||||
libusb1-devel >/dev/null
|
libusb1-devel \
|
||||||
|
bluez \
|
||||||
|
wiiuse \
|
||||||
|
wiiuse-devel \
|
||||||
|
libcap >/dev/null
|
||||||
NODE_BIN="$(command -v node)"
|
NODE_BIN="$(command -v node)"
|
||||||
|
|
||||||
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
||||||
@@ -62,6 +159,13 @@ EOF
|
|||||||
chmod 644 "$KINECT_UDEV_RULE"
|
chmod 644 "$KINECT_UDEV_RULE"
|
||||||
udevadm control --reload-rules
|
udevadm control --reload-rules
|
||||||
|
|
||||||
|
if [[ ! -f "$CHROMEGTTS_WAV_TEMPLATE" ]]; then
|
||||||
|
echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Installing Chrome Google TTS WAV helper -> $CHROMEGTTS_WAV_BIN"
|
||||||
|
install -m 0755 "$CHROMEGTTS_WAV_TEMPLATE" "$CHROMEGTTS_WAV_BIN"
|
||||||
|
|
||||||
echo "[2/6] Installing Node production deps..."
|
echo "[2/6] Installing Node production deps..."
|
||||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||||
|
|
||||||
@@ -70,12 +174,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
|
|||||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
|
||||||
|
echo " Building native Balance Board bridge..."
|
||||||
|
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
|
||||||
|
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
|
||||||
|
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Only this small audited bridge needs the management socket used for the
|
||||||
|
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
|
||||||
|
# front-button reconnects. Never grant either capability to node or the full
|
||||||
|
# multirover service executable.
|
||||||
|
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Bluetoothd remains responsible for discovery and the one-time bond, but its
|
||||||
|
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
|
||||||
|
# 0x13 before the Balance Board worker can listen for the board's front-button
|
||||||
|
# reconnect. This dedicated rover server gives those two HID listeners to the
|
||||||
|
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
|
||||||
|
# required by systemd before replacing the vendor unit's command in a drop-in.
|
||||||
|
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
|
||||||
|
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
|
||||||
|
EOF
|
||||||
|
chmod 0644 "$BLUETOOTH_OVERRIDE"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable bluetooth.service
|
||||||
|
systemctl restart bluetooth.service
|
||||||
|
|
||||||
tmpdir=$(mktemp -d)
|
tmpdir=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmpdir"' EXIT
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
@@ -83,12 +218,15 @@ arch=$(uname -m)
|
|||||||
case "$arch" in
|
case "$arch" in
|
||||||
x86_64|amd64)
|
x86_64|amd64)
|
||||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||||
|
neolink_pkg="neolink_linux_x86_64_ubuntu.zip"
|
||||||
;;
|
;;
|
||||||
aarch64)
|
aarch64)
|
||||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||||
|
neolink_pkg="neolink_linux_arm64.zip"
|
||||||
;;
|
;;
|
||||||
armv7l)
|
armv7l)
|
||||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||||
|
neolink_pkg="neolink_linux_armhf.zip"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unsupported architecture: $arch" >&2
|
echo "Unsupported architecture: $arch" >&2
|
||||||
@@ -101,6 +239,26 @@ curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
|||||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||||
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
||||||
|
|
||||||
|
echo " Installing neolink ${NEOLINK_VERSION} -> $NEOLINK_BIN"
|
||||||
|
curl -L "$NEOLINK_BASE_URL/$neolink_pkg" -o "$tmpdir/neolink.zip"
|
||||||
|
unzip -q "$tmpdir/neolink.zip" -d "$tmpdir/neolink"
|
||||||
|
neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink -perm /111 | head -n 1)
|
||||||
|
if [[ -z "$neolink_extracted" ]]; then
|
||||||
|
neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink | head -n 1)
|
||||||
|
fi
|
||||||
|
if [[ -z "$neolink_extracted" ]]; then
|
||||||
|
echo "neolink binary missing from $neolink_pkg" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
install -m 0755 "$neolink_extracted" "$NEOLINK_BIN"
|
||||||
|
install_google_tts_assets
|
||||||
|
if ! verify_google_tts_helper; then
|
||||||
|
echo " Reinstalling Google TTS assets after failed verification"
|
||||||
|
rm -rf /opt/roverd/googletts
|
||||||
|
install_google_tts_assets
|
||||||
|
verify_google_tts_helper
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||||
@@ -110,9 +268,12 @@ if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
|||||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
if [[ -f "$MEDIAMTX_CONFIG" ]]; then
|
||||||
rm -f "$MEDIAMTX_CONFIG"
|
echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
else
|
||||||
|
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||||
|
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||||
|
fi
|
||||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||||
@@ -144,8 +305,8 @@ EOF
|
|||||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Multi-Roomba Rover control server
|
Description=Multi-Roomba Rover control server
|
||||||
After=network-online.target mediamtx.service
|
After=network-online.target mediamtx.service bluetooth.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target bluetooth.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=$TARGET_USER
|
User=$TARGET_USER
|
||||||
@@ -182,3 +343,5 @@ echo
|
|||||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||||
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
||||||
|
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
|
||||||
|
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ esac
|
|||||||
|
|
||||||
mkdir -p "$SNAP_DIR"
|
mkdir -p "$SNAP_DIR"
|
||||||
|
|
||||||
|
FILTER="fps=1"
|
||||||
|
QUALITY="6"
|
||||||
|
|
||||||
|
case "$PATH_NAME" in
|
||||||
|
ptz-camera)
|
||||||
|
# PTZ snapshots are shown to non-operators specifically to avoid sending the
|
||||||
|
# full live video stream. The PTZ publisher is full-resolution 16:9 video,
|
||||||
|
# so resize the JPEGs at the snapshot writer boundary before Node ever reads
|
||||||
|
# and fans them out over Socket.IO.
|
||||||
|
FILTER="fps=1,scale=480:-2"
|
||||||
|
QUALITY="10"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
||||||
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
||||||
-an \
|
-an \
|
||||||
-vf fps=1 \
|
-vf "$FILTER" \
|
||||||
-q:v 6 \
|
-q:v "$QUALITY" \
|
||||||
-update 1 \
|
-update 1 \
|
||||||
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"obscenity": "^0.4.6",
|
"obscenity": "^0.4.6",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
|
"onvif": "^0.8.1",
|
||||||
|
"reolink-nvr-api": "^0.3.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.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
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
@@ -78,8 +78,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DRqsCa1W.js"></script>
|
<script type="module" crossorigin src="/assets/index-Da9ufxPv.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Bandwidth Savings Helper
|
||||||
|
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
|
||||||
|
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
|
||||||
|
// making individual services know raw YAML defaults or legacy config shapes.
|
||||||
|
const { loadConfig } = require('./configLoader');
|
||||||
|
|
||||||
|
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
|
||||||
|
const VIDEO_MODES = new Set(['snapshots', 'live']);
|
||||||
|
const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'admin']);
|
||||||
|
|
||||||
|
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||||
|
multiTabProtection: 'verifiedOnly',
|
||||||
|
nonTurnVideo: Object.freeze({
|
||||||
|
mode: 'snapshots',
|
||||||
|
userThreshold: 0,
|
||||||
|
}),
|
||||||
|
externalSpectatorVideo: 'snapshots',
|
||||||
|
externalSpectatorAccess: 'on',
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeEnum(value, allowed, fallback) {
|
||||||
|
/*
|
||||||
|
Config files are hand-edited on the server, so a typo should not crash the
|
||||||
|
process or silently broaden access. Each option falls back to the current
|
||||||
|
conservative behavior unless it exactly matches a known value.
|
||||||
|
*/
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return allowed.has(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonTurnVideo(value) {
|
||||||
|
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
|
const threshold = Number(raw.userThreshold);
|
||||||
|
/*
|
||||||
|
userThreshold is intentionally "greater than", not "greater than or equal".
|
||||||
|
A value of 4 means the first four controllable users can keep live non-turn
|
||||||
|
video, and the fifth controllable user activates snapshot saving. Invalid
|
||||||
|
or negative values fall back to zero, which preserves always-on snapshots
|
||||||
|
for any real non-turn participant.
|
||||||
|
*/
|
||||||
|
const userThreshold = Number.isFinite(threshold) ? Math.max(0, Math.floor(threshold)) : 0;
|
||||||
|
return {
|
||||||
|
mode: normalizeEnum(raw.mode, VIDEO_MODES, DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo.mode),
|
||||||
|
userThreshold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||||
|
const raw = config.bandwidthSavings || {};
|
||||||
|
return {
|
||||||
|
multiTabProtection: normalizeEnum(
|
||||||
|
raw.multiTabProtection,
|
||||||
|
MULTI_TAB_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||||
|
),
|
||||||
|
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
|
||||||
|
externalSpectatorVideo: normalizeEnum(
|
||||||
|
raw.externalSpectatorVideo,
|
||||||
|
VIDEO_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorVideo,
|
||||||
|
),
|
||||||
|
externalSpectatorAccess: normalizeEnum(
|
||||||
|
raw.externalSpectatorAccess,
|
||||||
|
EXTERNAL_SPECTATOR_ACCESS_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorAccess,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBandwidthSavingsPolicy() {
|
||||||
|
/*
|
||||||
|
loadConfig() is cached by configLoader, so rebuilding this small object per
|
||||||
|
caller is cheap while still letting tests pass explicit config objects into
|
||||||
|
buildBandwidthSavingsPolicy().
|
||||||
|
*/
|
||||||
|
return buildBandwidthSavingsPolicy(loadConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } = {}) {
|
||||||
|
const { multiTabProtection } = getBandwidthSavingsPolicy();
|
||||||
|
if (multiTabProtection === 'allowed') return false;
|
||||||
|
if (multiTabProtection === 'notAllowed') return true;
|
||||||
|
/*
|
||||||
|
verifiedOnly preserves the old behavior: trusted users can run multiple
|
||||||
|
driver tabs for operations/testing, while anonymous users are limited to one
|
||||||
|
active driver surface for fairness and bandwidth.
|
||||||
|
*/
|
||||||
|
return !isVerified && !isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseSnapshotsForNonTurnVideo({ controllableUserCount = 0 } = {}) {
|
||||||
|
const { nonTurnVideo } = getBandwidthSavingsPolicy();
|
||||||
|
if (nonTurnVideo.mode !== 'snapshots') return false;
|
||||||
|
/*
|
||||||
|
The threshold is evaluated centrally so MediaMTX auth, socket-issued video
|
||||||
|
tokens, PTZ authorization, and browser session state all agree. Using a
|
||||||
|
strict greater-than comparison makes the configured value read like the
|
||||||
|
maximum number of controllable users allowed before snapshots start.
|
||||||
|
*/
|
||||||
|
return Math.max(0, Number(controllableUserCount) || 0) > nonTurnVideo.userThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseSnapshotsForExternalSpectatorVideo() {
|
||||||
|
return getBandwidthSavingsPolicy().externalSpectatorVideo === 'snapshots';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseExternalSpectatorAccess({
|
||||||
|
isLocal = false,
|
||||||
|
isAdmin = false,
|
||||||
|
isVerified = false,
|
||||||
|
hasGrant = false,
|
||||||
|
} = {}) {
|
||||||
|
/*
|
||||||
|
Local/LAN spectators are not the upload-bandwidth problem, and admins need
|
||||||
|
to retain access for maintenance. The configured external mode only applies
|
||||||
|
to ordinary non-local spectator sockets.
|
||||||
|
*/
|
||||||
|
if (isLocal || isAdmin) return true;
|
||||||
|
const { externalSpectatorAccess } = getBandwidthSavingsPolicy();
|
||||||
|
if (externalSpectatorAccess === 'off') return false;
|
||||||
|
if (externalSpectatorAccess === 'verifiedOnly') return Boolean(isVerified);
|
||||||
|
if (externalSpectatorAccess === 'admin') return Boolean(hasGrant);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS,
|
||||||
|
buildBandwidthSavingsPolicy,
|
||||||
|
getBandwidthSavingsPolicy,
|
||||||
|
shouldEnforceSingleDriverTab,
|
||||||
|
shouldUseSnapshotsForNonTurnVideo,
|
||||||
|
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||||
|
canUseExternalSpectatorAccess,
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Feature Flags Helper
|
||||||
|
// Purpose: Normalizes optional server feature availability from config in one place.
|
||||||
|
// Scope: Keeps hardware/social visibility decisions out of individual UI panels and service callers.
|
||||||
|
const { loadConfig } = require('./configLoader');
|
||||||
|
|
||||||
|
function asBoolean(value, fallback = false) {
|
||||||
|
/*
|
||||||
|
Optional feature config is intentionally explicit. A missing `enabled` flag
|
||||||
|
means "off" for specialty hardware, which makes a fresh public install a
|
||||||
|
rover-only server until the operator opts into extra devices.
|
||||||
|
*/
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoomCameraEntries(config) {
|
||||||
|
const raw = config.roomCameras;
|
||||||
|
/*
|
||||||
|
The public config uses `{ enabled, cameras }` so the feature gate is obvious.
|
||||||
|
Accepting the old array shape here keeps the rest of the server from needing
|
||||||
|
to know which shape the local config file currently uses.
|
||||||
|
*/
|
||||||
|
if (Array.isArray(raw)) return raw;
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray(raw.cameras)) return raw.cameras;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredSocials(config) {
|
||||||
|
/*
|
||||||
|
Social links have an explicit feature switch. Entries under `links` are just
|
||||||
|
available data; they do not enable the Links panel by existing.
|
||||||
|
*/
|
||||||
|
const links = config.socials && typeof config.socials === 'object' ? config.socials.links : [];
|
||||||
|
return Array.isArray(links)
|
||||||
|
? links.filter((entry) => asTrimmedString(entry?.url))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeatureFlags(config = loadConfig()) {
|
||||||
|
const homeAssistantConfig = config.homeAssistant || {};
|
||||||
|
const roomCameraConfig = config.roomCameras || {};
|
||||||
|
const kinectConfig = config.kinect || {};
|
||||||
|
const buttonBoxConfig = config.buttonBox || {};
|
||||||
|
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||||
|
const balanceBoardConfig = config.balanceBoard || {};
|
||||||
|
const barcodeGamesConfig = config.barcodeGames || {};
|
||||||
|
const socialsConfig = config.socials || {};
|
||||||
|
const interInstanceConfig = config.interInstance || {};
|
||||||
|
const ptzCameraConfig = config.ptzCamera || {};
|
||||||
|
const discordConfig = config.discord || {};
|
||||||
|
const homeAssistant = Boolean(
|
||||||
|
asBoolean(homeAssistantConfig.enabled) &&
|
||||||
|
asTrimmedString(homeAssistantConfig.url) &&
|
||||||
|
asTrimmedString(homeAssistantConfig.token),
|
||||||
|
);
|
||||||
|
const roomCameraEntries = getRoomCameraEntries(config);
|
||||||
|
const roomCamerasEnabled = Array.isArray(config.roomCameras)
|
||||||
|
? false
|
||||||
|
: asBoolean(roomCameraConfig.enabled);
|
||||||
|
const barcodeScanner = asBoolean(barcodeScannerConfig.enabled);
|
||||||
|
|
||||||
|
return {
|
||||||
|
homeAssistant,
|
||||||
|
roomCameras: Boolean(roomCamerasEnabled && roomCameraEntries.length),
|
||||||
|
kinect: asBoolean(kinectConfig.enabled),
|
||||||
|
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||||
|
barcodeScanner,
|
||||||
|
// The worker performs its own runtime availability reporting. Advertising
|
||||||
|
// the feature from the explicit config switch lets the UI show useful
|
||||||
|
// commissioning and hardware-error states even before a board is paired.
|
||||||
|
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||||
|
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||||
|
lift: Boolean(
|
||||||
|
homeAssistant &&
|
||||||
|
asBoolean(homeAssistantConfig.lift?.enabled) &&
|
||||||
|
asTrimmedString(homeAssistantConfig.lift?.upSwitch) &&
|
||||||
|
asTrimmedString(homeAssistantConfig.lift?.downSwitch),
|
||||||
|
),
|
||||||
|
neato: Boolean(
|
||||||
|
homeAssistant &&
|
||||||
|
asBoolean(homeAssistantConfig.neato?.enabled) &&
|
||||||
|
asTrimmedString(homeAssistantConfig.neato?.device),
|
||||||
|
),
|
||||||
|
socials: Boolean(asBoolean(socialsConfig.enabled) && getConfiguredSocials(config).length > 0),
|
||||||
|
interInstance: asBoolean(interInstanceConfig.enabled),
|
||||||
|
ptzCamera: Boolean(
|
||||||
|
asBoolean(ptzCameraConfig.enabled) &&
|
||||||
|
asTrimmedString(ptzCameraConfig.host) &&
|
||||||
|
asTrimmedString(ptzCameraConfig.username) &&
|
||||||
|
asTrimmedString(ptzCameraConfig.password),
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
Discord is an optional transport, not a prerequisite for chat commands.
|
||||||
|
Requiring both the explicit switch and a token prevents an old token from
|
||||||
|
silently enabling external connections on installations that have chosen
|
||||||
|
to run without the integration.
|
||||||
|
*/
|
||||||
|
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureFlags() {
|
||||||
|
return buildFeatureFlags(loadConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFeatureEnabled(featureName) {
|
||||||
|
return Boolean(getFeatureFlags()[featureName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildFeatureFlags,
|
||||||
|
getFeatureFlags,
|
||||||
|
isFeatureEnabled,
|
||||||
|
getRoomCameraEntries,
|
||||||
|
getConfiguredSocials,
|
||||||
|
};
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
// Reward Definition: Darkness
|
// Reward Definition: Darkness
|
||||||
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
||||||
const DURATION_MS = 15 * 60 * 1000;
|
const DURATION_MS = 15 * 60 * 1000;
|
||||||
const LIGHT_ENFORCE_TICK_MS = 3000;
|
|
||||||
|
|
||||||
let activeTimer = null;
|
let activeTimer = null;
|
||||||
let enforceLightsTimer = null;
|
|
||||||
let headlightLockUntil = 0;
|
let headlightLockUntil = 0;
|
||||||
|
|
||||||
function isHeadlightBlocked() {
|
function isHeadlightBlocked() {
|
||||||
@@ -16,10 +14,6 @@ function clearTimers() {
|
|||||||
clearTimeout(activeTimer);
|
clearTimeout(activeTimer);
|
||||||
activeTimer = null;
|
activeTimer = null;
|
||||||
}
|
}
|
||||||
if (enforceLightsTimer) {
|
|
||||||
clearInterval(enforceLightsTimer);
|
|
||||||
enforceLightsTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceAllLightsOff(ctx) {
|
async function forceAllLightsOff(ctx) {
|
||||||
@@ -55,7 +49,6 @@ async function stopDarkness(ctx, effect = {}) {
|
|||||||
if (prevLockState === 'on' || prevLockState === 'off') {
|
if (prevLockState === 'on' || prevLockState === 'off') {
|
||||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||||
source: 'buttonbox:darknessRestore',
|
source: 'buttonbox:darknessRestore',
|
||||||
forceApply: true,
|
|
||||||
targetState: prevLockState,
|
targetState: prevLockState,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -92,7 +85,6 @@ async function startDarkness(ctx, effect) {
|
|||||||
try {
|
try {
|
||||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||||
source: 'buttonbox:darkness',
|
source: 'buttonbox:darkness',
|
||||||
forceApply: true,
|
|
||||||
targetState: 'off',
|
targetState: 'off',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -100,11 +92,13 @@ async function startDarkness(ctx, effect) {
|
|||||||
}
|
}
|
||||||
ctx.saveEffect('darkness', effect);
|
ctx.saveEffect('darkness', effect);
|
||||||
|
|
||||||
enforceLightsTimer = setInterval(() => {
|
/*
|
||||||
forceAllLightsOff(ctx).catch((err) => {
|
Darkness locks the room-light policy off and performs the initial off
|
||||||
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
|
command through setHomeAssistantLightsLockedOn above. It deliberately does
|
||||||
});
|
not keep a polling interval that re-forces Home Assistant entities off:
|
||||||
}, LIGHT_ENFORCE_TICK_MS);
|
after the lock is established, out-of-band manual controls must remain able
|
||||||
|
to change individual room lights without the server fighting them.
|
||||||
|
*/
|
||||||
|
|
||||||
activeTimer = setTimeout(() => {
|
activeTimer = setTimeout(() => {
|
||||||
stopDarkness(ctx, effect).catch((err) => {
|
stopDarkness(ctx, effect).catch((err) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Reward Definition: Light Strobe
|
// Reward Definition: Light Strobe
|
||||||
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
||||||
const STROBE_MS = 30 * 1000;
|
const STROBE_MS = 60 * 1000;
|
||||||
const TICK_MS = 500;
|
const TICK_MS = 1500;
|
||||||
|
|
||||||
let activeTimer = null;
|
let activeTimer = null;
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ module.exports = {
|
|||||||
goal: 400,
|
goal: 400,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
|
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 60 seconds.' });
|
||||||
},
|
},
|
||||||
async recover(ctx, effect) {
|
async recover(ctx, effect) {
|
||||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||||
|
|||||||
@@ -13,6 +13,37 @@ const assignments = new Map(); // socketId -> roverId
|
|||||||
const waiting = new Set(); // socketIds waiting for placement
|
const waiting = new Set(); // socketIds waiting for placement
|
||||||
const assignmentEvents = new EventEmitter();
|
const assignmentEvents = new EventEmitter();
|
||||||
|
|
||||||
|
function normalizeRemovalMessage(message, fallback) {
|
||||||
|
/*
|
||||||
|
Removal notices are shown directly in the driving UI, so the server trims
|
||||||
|
caller-provided text before emitting it. Keeping this normalization close to
|
||||||
|
the release helper makes every forced-removal path use the same readable
|
||||||
|
fallback instead of forcing each caller to duplicate defensive string checks.
|
||||||
|
*/
|
||||||
|
const clean = String(message || '').trim();
|
||||||
|
return clean || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitRemovalNotice(socket, notice = {}) {
|
||||||
|
/*
|
||||||
|
The browser may lose its rover assignment in the same server tick that the
|
||||||
|
reason is generated. Sending a dedicated event before releasing control lets
|
||||||
|
the client preserve the explanation even after normal session sync says the
|
||||||
|
user no longer has an assigned rover.
|
||||||
|
*/
|
||||||
|
if (!socket) return;
|
||||||
|
const roverId = String(notice.roverId || '').trim() || null;
|
||||||
|
const message = normalizeRemovalMessage(notice.message, 'You were removed from the rover.');
|
||||||
|
socket.emit('session:roverRemovalNotice', {
|
||||||
|
roverId,
|
||||||
|
title: normalizeRemovalMessage(notice.title, 'Removed from rover'),
|
||||||
|
message,
|
||||||
|
reasonCode: String(notice.reasonCode || 'removed').trim() || 'removed',
|
||||||
|
actor: notice.actor || null,
|
||||||
|
ts: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socketRefs.set(socket.id, socket);
|
socketRefs.set(socket.id, socket);
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
@@ -176,6 +207,18 @@ function forceRelease(roverId, socketId) {
|
|||||||
assignmentEvents.emit('update', socketId);
|
assignmentEvents.emit('update', socketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forceReleaseWithNotice(roverId, socketId, notice = {}) {
|
||||||
|
/*
|
||||||
|
This is the one public path for moderation-style removals. It deliberately
|
||||||
|
emits the explanation before forceRelease mutates assignment state, because
|
||||||
|
session sync listeners can update the UI immediately after the release and
|
||||||
|
the UI needs the reason to already be in local state.
|
||||||
|
*/
|
||||||
|
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
||||||
|
emitRemovalNotice(socket, { ...notice, roverId });
|
||||||
|
forceRelease(roverId, socketId);
|
||||||
|
}
|
||||||
|
|
||||||
function pickRover(socket, options = {}) {
|
function pickRover(socket, options = {}) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||||
@@ -266,6 +309,7 @@ module.exports = {
|
|||||||
assignmentEvents,
|
assignmentEvents,
|
||||||
describeAssignment,
|
describeAssignment,
|
||||||
forceRelease,
|
forceRelease,
|
||||||
|
forceReleaseWithNotice,
|
||||||
rerollAssignments,
|
rerollAssignments,
|
||||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||||
|
|||||||
@@ -8,9 +8,20 @@ const { loadConfig } = require('../../helpers/configLoader');
|
|||||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
const { clearLockdownTimer } = require('../lockdownGuard');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { setRole } = require('../roleService');
|
const { setRole } = require('../roleService');
|
||||||
|
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||||
|
const {
|
||||||
|
canUseExternalSpectatorAccess,
|
||||||
|
getBandwidthSavingsPolicy,
|
||||||
|
} = require('../../helpers/bandwidthSavings');
|
||||||
|
const {
|
||||||
|
getFeatureState,
|
||||||
|
getUserIdForSocket,
|
||||||
|
updateFeatureState,
|
||||||
|
} = require('../identityService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const admins = config.admins || [];
|
const admins = config.admins || [];
|
||||||
|
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||||
|
|
||||||
function findAdmin(username) {
|
function findAdmin(username) {
|
||||||
return admins.find((admin) => admin.username === username);
|
return admins.find((admin) => admin.username === username);
|
||||||
@@ -36,9 +47,91 @@ function isLockdownAdmin(socket) {
|
|||||||
return socket?.data?.role === 'lockdown';
|
return socket?.data?.role === 'lockdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExternalSpectatorGrant(socket) {
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
if (!userId) return false;
|
||||||
|
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
|
||||||
|
/*
|
||||||
|
The identity database already owns per-user feature state. Keeping the grant
|
||||||
|
as a tiny namespaced boolean avoids a new table and lets the existing admin
|
||||||
|
database editor grant/revoke external spectator access immediately.
|
||||||
|
*/
|
||||||
|
return Boolean(state?.external);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canBecomeSpectator(socket) {
|
||||||
|
const ip = getSocketIp(socket);
|
||||||
|
const local = isLocalNetwork(ip);
|
||||||
|
return canUseExternalSpectatorAccess({
|
||||||
|
isLocal: local,
|
||||||
|
isAdmin: isAdmin(socket),
|
||||||
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
|
hasGrant: hasExternalSpectatorGrant(socket),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalSpectatorAccessError() {
|
||||||
|
const mode = getBandwidthSavingsPolicy().externalSpectatorAccess;
|
||||||
|
if (mode === 'verifiedOnly') {
|
||||||
|
return 'External spectator access requires a verified identity.';
|
||||||
|
}
|
||||||
|
if (mode === 'admin') {
|
||||||
|
return 'External spectator access requires admin approval for this identity.';
|
||||||
|
}
|
||||||
|
return 'External spectator access is disabled.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantExternalSpectatorAccessAfterAdminLogin(socket) {
|
||||||
|
const policy = getBandwidthSavingsPolicy();
|
||||||
|
if (policy.externalSpectatorAccess !== 'admin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ip = getSocketIp(socket);
|
||||||
|
if (isLocalNetwork(ip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
if (!userId) {
|
||||||
|
/*
|
||||||
|
Sockets are normally identified on connection before login, but keeping a
|
||||||
|
guard here makes the admin grant fail closed instead of writing an orphan
|
||||||
|
feature-state row if identity setup changes later.
|
||||||
|
*/
|
||||||
|
logger.warn('External spectator grant skipped because socket has no identity', { socketId: socket?.id });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
updateFeatureState(
|
||||||
|
userId,
|
||||||
|
SPECTATOR_ACCESS_NAMESPACE,
|
||||||
|
(current) => ({
|
||||||
|
/*
|
||||||
|
Preserve any future spectatorAccess settings beside `external`. The
|
||||||
|
login flow is only approving this identity for external spectating, not
|
||||||
|
resetting the whole namespace back to a one-field object.
|
||||||
|
*/
|
||||||
|
...(current || {}),
|
||||||
|
external: true,
|
||||||
|
grantedByAdminLoginAt: Date.now(),
|
||||||
|
grantedByAdminUsername: socket?.data?.user?.username || null,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
logger.info('External spectator access granted after admin login', {
|
||||||
|
socketId: socket.id,
|
||||||
|
userId,
|
||||||
|
username: socket?.data?.user?.username || null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const requestedRole = socket.handshake?.query?.role;
|
const requestedRole = socket.handshake?.query?.role;
|
||||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
/*
|
||||||
|
Role is assigned before the browser's full identity heartbeat has completed.
|
||||||
|
For admin-gated external spectators, fail closed here; the spectator page can
|
||||||
|
identify the socket and then retry session:setRole once the grant exists.
|
||||||
|
*/
|
||||||
|
const initialRole = requestedRole === 'spectator' && canBecomeSpectator(socket) ? 'spectator' : 'user';
|
||||||
setRole(socket, initialRole);
|
setRole(socket, initialRole);
|
||||||
logger.info('Socket connected with role', socket.id, initialRole);
|
logger.info('Socket connected with role', socket.id, initialRole);
|
||||||
socket.emit('auth:role', { role: initialRole });
|
socket.emit('auth:role', { role: initialRole });
|
||||||
@@ -51,6 +144,13 @@ io.on('connection', (socket) => {
|
|||||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
const role = admin.lockdown ? 'lockdown' : 'admin';
|
||||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
||||||
setRole(socket, role);
|
setRole(socket, role);
|
||||||
|
/*
|
||||||
|
In admin-gated external spectator mode, logging in from /spectate is the
|
||||||
|
approval action for this browser identity. Persist the grant before the
|
||||||
|
client retries switching back to spectator, otherwise the user would
|
||||||
|
lose the admin bypass and immediately fall back into the gate.
|
||||||
|
*/
|
||||||
|
grantExternalSpectatorAccessAfterAdminLogin(socket);
|
||||||
socket.emit('auth:role', { role });
|
socket.emit('auth:role', { role });
|
||||||
clearLockdownTimer(socket);
|
clearLockdownTimer(socket);
|
||||||
logger.info('Login success', socket.id, role);
|
logger.info('Login success', socket.id, role);
|
||||||
@@ -63,6 +163,12 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
function handleRoleChange({ role } = {}, cb = () => {}) {
|
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||||
if (role === 'spectator' || role === 'user') {
|
if (role === 'spectator' || role === 'user') {
|
||||||
|
if (role === 'spectator' && !canBecomeSpectator(socket)) {
|
||||||
|
const error = externalSpectatorAccessError();
|
||||||
|
logger.info('Spectator role denied by bandwidth policy', socket.id, { error });
|
||||||
|
cb({ error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRole(socket, role);
|
setRole(socket, role);
|
||||||
socket.emit('auth:role', { role });
|
socket.emit('auth:role', { role });
|
||||||
logger.info('Role changed via client request', socket.id, role);
|
logger.info('Role changed via client request', socket.id, role);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Balance Board Hardware Bridge
|
||||||
|
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
|
||||||
|
// Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js.
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WORKER_PATH =
|
||||||
|
process.env.BALANCE_BOARD_WORKER ||
|
||||||
|
path.join(__dirname, 'native', 'balance_board_worker');
|
||||||
|
const RESTART_DELAY_MS = 2000;
|
||||||
|
const STDERR_LOG_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) {
|
||||||
|
const events = new EventEmitter();
|
||||||
|
let worker = null;
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
let stopped = false;
|
||||||
|
let restarting = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
let lastStderrLogAt = 0;
|
||||||
|
let suppressedStderrLines = 0;
|
||||||
|
let currentAddress = address;
|
||||||
|
|
||||||
|
function emitProtocolError(message) {
|
||||||
|
events.emit('message', {
|
||||||
|
type: 'status',
|
||||||
|
state: 'error',
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function processStdout(chunk) {
|
||||||
|
stdoutBuffer += chunk.toString('utf8');
|
||||||
|
let newline = stdoutBuffer.indexOf('\n');
|
||||||
|
while (newline !== -1) {
|
||||||
|
const line = stdoutBuffer.slice(0, newline).trim();
|
||||||
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
||||||
|
if (line) {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||||
|
throw new Error('message needs a type');
|
||||||
|
}
|
||||||
|
events.emit('message', message);
|
||||||
|
} catch (err) {
|
||||||
|
// A corrupted stdout line means measurement framing can no longer be
|
||||||
|
// trusted. Surface the exact line rather than silently discarding a
|
||||||
|
// potential hardware failure that would otherwise look like zero kg.
|
||||||
|
emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`);
|
||||||
|
logger?.warn?.('Balance Board worker protocol error', { line, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newline = stdoutBuffer.indexOf('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRestart() {
|
||||||
|
if (stopped || restartTimer) return;
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = null;
|
||||||
|
start();
|
||||||
|
}, RESTART_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopped || (worker && !worker.killed)) return;
|
||||||
|
stdoutBuffer = '';
|
||||||
|
|
||||||
|
const child = spawn(WORKER_PATH, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BALANCE_BOARD_ADDRESS: currentAddress || '',
|
||||||
|
BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
worker = child;
|
||||||
|
|
||||||
|
child.stdout.on('data', processStdout);
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString('utf8').trim();
|
||||||
|
if (!text) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) {
|
||||||
|
const suffix = suppressedStderrLines
|
||||||
|
? ` (${suppressedStderrLines} worker stderr lines suppressed)`
|
||||||
|
: '';
|
||||||
|
logger?.warn?.(`Balance Board worker: ${text}${suffix}`);
|
||||||
|
lastStderrLogAt = now;
|
||||||
|
suppressedStderrLines = 0;
|
||||||
|
} else {
|
||||||
|
suppressedStderrLines += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (worker === child) worker = null;
|
||||||
|
emitProtocolError(`balance board worker failed to start: ${err.message}`);
|
||||||
|
scheduleRestart();
|
||||||
|
});
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
if (worker === child) worker = null;
|
||||||
|
if (!stopped) {
|
||||||
|
// Admin unpair deliberately replaces the worker with an empty address.
|
||||||
|
// Do not turn that expected exit into a red hardware-error state while
|
||||||
|
// still using the normal restart scheduler for the replacement.
|
||||||
|
if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`);
|
||||||
|
restarting = false;
|
||||||
|
scheduleRestart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
stopped = true;
|
||||||
|
restarting = false;
|
||||||
|
if (restartTimer) {
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
restartTimer = null;
|
||||||
|
}
|
||||||
|
if (!worker) return;
|
||||||
|
const child = worker;
|
||||||
|
worker = null;
|
||||||
|
try {
|
||||||
|
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||||
|
} catch (_err) {
|
||||||
|
// The worker may have already closed stdin while its exit event is still
|
||||||
|
// queued. SIGTERM below remains the reliable cleanup path.
|
||||||
|
}
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
// bluetoothctl may still be finishing a bounded pairing command inside a
|
||||||
|
// worker thread. Do not let that delay server shutdown indefinitely.
|
||||||
|
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||||
|
}, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (!worker) {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = worker;
|
||||||
|
restarting = true;
|
||||||
|
try {
|
||||||
|
// An admin forget changes the address used in the child environment. A
|
||||||
|
// controlled restart lets the replacement worker start with that new
|
||||||
|
// value, while the existing close handler remains the single owner of
|
||||||
|
// delayed respawn and avoids overlapping Bluetooth listeners.
|
||||||
|
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||||
|
} catch (_err) {
|
||||||
|
// The child may have already closed stdin; SIGTERM below still guarantees
|
||||||
|
// that it cannot keep listening for the address that was just forgotten.
|
||||||
|
}
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||||
|
}, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
restart,
|
||||||
|
setAddress(nextAddress) {
|
||||||
|
// The factory can be created before first commissioning. Preserve the
|
||||||
|
// newly paired address for later bridge restarts in the same Node process
|
||||||
|
// instead of reverting the replacement worker to discovery mode.
|
||||||
|
currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createBalanceBoardHardware,
|
||||||
|
};
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
// Balance Board Service
|
||||||
|
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
|
||||||
|
// Scope: Stores pairing and admin zero calibration, then publishes status plus live four-corner weight.
|
||||||
|
const fs = require('fs');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('balanceBoardService');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
|
const { isAdmin } = require('../roleService');
|
||||||
|
const { sendAlert } = require('../alertService');
|
||||||
|
const { createBalanceBoardHardware } = require('./hardware');
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const enabled = isFeatureEnabled('balanceBoard');
|
||||||
|
const rawConfig = loadConfig().balanceBoard || {};
|
||||||
|
const DATA_DIR = resolveDataDir();
|
||||||
|
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||||
|
const FRAME_ROOM = 'balance-board-viewers';
|
||||||
|
const CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
|
||||||
|
const ZERO_SAMPLE_COUNT = 10;
|
||||||
|
const ZERO_SAMPLE_INTERVAL_MS = 1000;
|
||||||
|
const ZERO_MAX_SAMPLE_AGE_MS = 1500;
|
||||||
|
const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
|
||||||
|
const RECORD_PERSIST_DELAY_MS = 1000;
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const ALERT_COLOR = '#38bdf8';
|
||||||
|
|
||||||
|
function emptyZeroCorners() {
|
||||||
|
return Object.fromEntries(CORNER_KEYS.map((key) => [key, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoredCorners(value) {
|
||||||
|
if (!value || typeof value !== 'object') return emptyZeroCorners();
|
||||||
|
return Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||||
|
const number = Number(value[key]);
|
||||||
|
return [key, Number.isFinite(number) ? Math.max(0, number) : 0];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStore() {
|
||||||
|
return {
|
||||||
|
address: '',
|
||||||
|
zeroCorners: emptyZeroCorners(),
|
||||||
|
zeroedAt: null,
|
||||||
|
recordKg: 0,
|
||||||
|
recordedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStore() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
|
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
||||||
|
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
|
||||||
|
const recordKg = Number.isFinite(Number(parsed?.recordKg))
|
||||||
|
? roundedWeight(parsed.recordKg)
|
||||||
|
: 0;
|
||||||
|
const recordedAt = Number.isFinite(Number(parsed?.recordedAt))
|
||||||
|
? Number(parsed.recordedAt)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
|
||||||
|
zeroedAt,
|
||||||
|
recordKg,
|
||||||
|
recordedAt: recordKg > 0 ? recordedAt : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
|
||||||
|
return emptyStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistStore() {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
||||||
|
fs.renameSync(temporary, STORE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedWeight(value) {
|
||||||
|
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornerWeightsKg(corners = {}) {
|
||||||
|
// Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
|
||||||
|
// admin zero calibration below is an installation baseline layered on top of
|
||||||
|
// this factory conversion; it must never replace the hardware calibration.
|
||||||
|
return {
|
||||||
|
topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
|
||||||
|
bottomRight: roundedWeight((Number(corners.bottomRight) || 0) / 100),
|
||||||
|
topLeft: roundedWeight((Number(corners.topLeft) || 0) / 100),
|
||||||
|
bottomLeft: roundedWeight((Number(corners.bottomLeft) || 0) / 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractZero(rawCorners) {
|
||||||
|
const baseline = store.zeroedAt ? store.zeroCorners : emptyZeroCorners();
|
||||||
|
return Object.fromEntries(CORNER_KEYS.map((key) => [
|
||||||
|
key,
|
||||||
|
roundedWeight(Math.max(0, rawCorners[key] - baseline[key])),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalCornerWeight(corners) {
|
||||||
|
return roundedWeight(CORNER_KEYS.reduce((total, key) => total + corners[key], 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = enabled ? loadStore() : emptyStore();
|
||||||
|
let hardware = null;
|
||||||
|
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
|
||||||
|
let detail = enabled
|
||||||
|
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
|
||||||
|
: 'Balance Board support is disabled.';
|
||||||
|
let connected = false;
|
||||||
|
let batteryPercent = null;
|
||||||
|
let latestFrame = null;
|
||||||
|
let latestRawCorners = null;
|
||||||
|
let latestRawFrameAt = 0;
|
||||||
|
let zeroTimer = null;
|
||||||
|
let recordPersistTimer = null;
|
||||||
|
let zeroSamples = [];
|
||||||
|
let zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
let previousWorkerState = '';
|
||||||
|
let lastAlertKey = '';
|
||||||
|
let unpairing = false;
|
||||||
|
|
||||||
|
function sendRawAlert(state, message = '') {
|
||||||
|
const rawMessage = message ? `${state}: ${message}` : state;
|
||||||
|
if (rawMessage === lastAlertKey) return;
|
||||||
|
lastAlertKey = rawMessage;
|
||||||
|
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendStatusAlert(workerState, message = '') {
|
||||||
|
const shouldAlert =
|
||||||
|
workerState === 'connected' ||
|
||||||
|
workerState === 'sleeping' ||
|
||||||
|
workerState === 'connection-failed' ||
|
||||||
|
workerState === 'error' ||
|
||||||
|
(workerState === 'waiting' && previousWorkerState === 'connected');
|
||||||
|
|
||||||
|
previousWorkerState = workerState;
|
||||||
|
if (!shouldAlert) return;
|
||||||
|
|
||||||
|
// Keep the alert at the same system-level boundary as the worker protocol:
|
||||||
|
// state first, followed by its exact detail when one exists. The service does
|
||||||
|
// not reinterpret failures as friendlier product copy, but still collapses
|
||||||
|
// identical retries so a failing reconnect cannot flood the activity feed.
|
||||||
|
sendRawAlert(workerState, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
|
||||||
|
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
|
||||||
|
connected,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
batteryPercent,
|
||||||
|
recordKg: store.recordKg,
|
||||||
|
recordedAt: store.recordedAt,
|
||||||
|
calibration: {
|
||||||
|
calibrated: Boolean(store.zeroedAt),
|
||||||
|
zeroedAt: store.zeroedAt,
|
||||||
|
...zeroProgress,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecordPersistTimer() {
|
||||||
|
if (!recordPersistTimer) return;
|
||||||
|
clearTimeout(recordPersistTimer);
|
||||||
|
recordPersistTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRecordPersistence() {
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
|
||||||
|
// A person driving onto the board produces many successively larger frames.
|
||||||
|
// Waiting until the maximum has stopped changing prevents a synchronous JSON
|
||||||
|
// rewrite for every 20 Hz sensor frame while still saving a settled record
|
||||||
|
// promptly enough to survive an ordinary service restart.
|
||||||
|
recordPersistTimer = setTimeout(() => {
|
||||||
|
recordPersistTimer = null;
|
||||||
|
persistStore();
|
||||||
|
}, RECORD_PERSIST_DELAY_MS);
|
||||||
|
recordPersistTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishLatestFrame() {
|
||||||
|
if (!latestFrame) return;
|
||||||
|
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWeightRecord() {
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
|
||||||
|
// Reset means "start measuring the record from now." If the board currently
|
||||||
|
// has a load, that current measurement is the first candidate in the new
|
||||||
|
// period. Saving it immediately avoids briefly showing zero before the next
|
||||||
|
// live frame restores the same weight as the record.
|
||||||
|
const currentWeight = connected && latestFrame ? roundedWeight(latestFrame.totalKg) : 0;
|
||||||
|
store.recordKg = currentWeight;
|
||||||
|
store.recordedAt = currentWeight > 0 ? Date.now() : null;
|
||||||
|
persistStore();
|
||||||
|
|
||||||
|
if (latestFrame) {
|
||||||
|
latestFrame = {
|
||||||
|
...latestFrame,
|
||||||
|
recordKg: store.recordKg,
|
||||||
|
recordedAt: store.recordedAt,
|
||||||
|
};
|
||||||
|
publishLatestFrame();
|
||||||
|
}
|
||||||
|
events.emit('change', { state: getState() });
|
||||||
|
sendRawAlert('record-reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(nextStatus, nextDetail) {
|
||||||
|
const normalizedStatus = String(nextStatus || 'unknown');
|
||||||
|
const normalizedDetail = String(nextDetail || '');
|
||||||
|
if (status === normalizedStatus && detail === normalizedDetail) return;
|
||||||
|
status = normalizedStatus;
|
||||||
|
detail = normalizedDetail;
|
||||||
|
events.emit('change', { state: getState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishCalibrationState() {
|
||||||
|
// Calibration progress belongs in the ordinary session payload because it
|
||||||
|
// changes only once per second for ten seconds. Live 20 Hz weights remain in
|
||||||
|
// their dedicated room and never trigger a full-session broadcast.
|
||||||
|
events.emit('change', { state: getState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearZeroTimer() {
|
||||||
|
if (!zeroTimer) return;
|
||||||
|
clearInterval(zeroTimer);
|
||||||
|
zeroTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failZeroCalibration(error, { alert = true } = {}) {
|
||||||
|
clearZeroTimer();
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: String(error || 'Calibration failed'),
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
if (alert) sendRawAlert('zero-failed', zeroProgress.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishZeroCalibration() {
|
||||||
|
clearZeroTimer();
|
||||||
|
|
||||||
|
// A single average could hide movement that returns to its starting point.
|
||||||
|
// Sum every corner's complete ten-second range before accepting the result so
|
||||||
|
// distributed movement cannot hide below four independent thresholds. Retain
|
||||||
|
// three decimals so averaging ten centi-kilogram samples does not throw away
|
||||||
|
// useful sub-centi-kilogram precision in the persisted baseline.
|
||||||
|
const combinedRange = CORNER_KEYS.reduce((totalRange, key) => {
|
||||||
|
const values = zeroSamples.map((sample) => sample[key]);
|
||||||
|
return totalRange + Math.max(...values) - Math.min(...values);
|
||||||
|
}, 0);
|
||||||
|
if (combinedRange > ZERO_MAX_COMBINED_RANGE_KG) {
|
||||||
|
failZeroCalibration('Load moved during the ten-second calibration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.zeroCorners = Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||||
|
const average = zeroSamples.reduce((sum, sample) => sum + sample[key], 0) /
|
||||||
|
zeroSamples.length;
|
||||||
|
return [key, Math.round(average * 1000) / 1000];
|
||||||
|
}));
|
||||||
|
store.zeroedAt = Date.now();
|
||||||
|
// A new zero changes the meaning of every adjusted weight, so an old record
|
||||||
|
// cannot be compared with measurements under the new baseline.
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
store.recordKg = 0;
|
||||||
|
store.recordedAt = null;
|
||||||
|
persistStore();
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: ZERO_SAMPLE_COUNT,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
sendRawAlert('zeroed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeZeroSample() {
|
||||||
|
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||||
|
failZeroCalibration('Live Balance Board data stopped during calibration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroSamples.push({ ...latestRawCorners });
|
||||||
|
zeroProgress = {
|
||||||
|
active: true,
|
||||||
|
samplesCollected: zeroSamples.length,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
if (zeroSamples.length >= ZERO_SAMPLE_COUNT) finishZeroCalibration();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startZeroCalibration() {
|
||||||
|
if (zeroProgress.active) throw new Error('Balance Board zero calibration is already running');
|
||||||
|
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||||
|
throw new Error('The Balance Board must be connected and sending weight data');
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: true,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
sendRawAlert('zeroing');
|
||||||
|
// Delaying the first sample by one interval makes this a real ten-second
|
||||||
|
// calibration rather than ten rapid reads followed by nine seconds of UI.
|
||||||
|
zeroTimer = setInterval(takeZeroSample, ZERO_SAMPLE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFrame(message = {}) {
|
||||||
|
const rawCorners = cornerWeightsKg(message.corners);
|
||||||
|
latestRawCorners = rawCorners;
|
||||||
|
latestRawFrameAt = Date.now();
|
||||||
|
if (Number.isFinite(Number(message.batteryPercent))) {
|
||||||
|
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
connected = true;
|
||||||
|
updateStatus('connected', 'Live weight is updating.');
|
||||||
|
const adjustedCorners = subtractZero(rawCorners);
|
||||||
|
const totalKg = totalCornerWeight(adjustedCorners);
|
||||||
|
if (totalKg > store.recordKg) {
|
||||||
|
// Store only adjusted weight so the displayed record uses the same admin
|
||||||
|
// zero baseline as the live total and all four corner readings.
|
||||||
|
store.recordKg = totalKg;
|
||||||
|
store.recordedAt = Date.now();
|
||||||
|
scheduleRecordPersistence();
|
||||||
|
}
|
||||||
|
latestFrame = {
|
||||||
|
totalKg,
|
||||||
|
corners: adjustedCorners,
|
||||||
|
batteryPercent,
|
||||||
|
recordKg: store.recordKg,
|
||||||
|
recordedAt: store.recordedAt,
|
||||||
|
};
|
||||||
|
publishLatestFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkerMessage(message = {}) {
|
||||||
|
if (message.type === 'frame') {
|
||||||
|
processFrame(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'paired') {
|
||||||
|
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
||||||
|
if (address && address !== store.address) {
|
||||||
|
store.address = address;
|
||||||
|
// A zero baseline belongs to one physical board and whatever permanent
|
||||||
|
// platform/load was present when an admin calibrated it. Never carry that
|
||||||
|
// baseline across commissioning a different Bluetooth identity.
|
||||||
|
store.zeroCorners = emptyZeroCorners();
|
||||||
|
store.zeroedAt = null;
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
store.recordKg = 0;
|
||||||
|
store.recordedAt = null;
|
||||||
|
persistStore();
|
||||||
|
}
|
||||||
|
hardware?.setAddress(address);
|
||||||
|
sendRawAlert('paired');
|
||||||
|
updateStatus('connecting', 'Paired. Connecting to the board now.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type !== 'status') return;
|
||||||
|
const workerState = String(message.state || 'unknown');
|
||||||
|
sendStatusAlert(workerState, message.error || '');
|
||||||
|
if (workerState === 'commissioning') {
|
||||||
|
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||||
|
} else if (workerState === 'discovering') {
|
||||||
|
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
|
||||||
|
} else if (workerState === 'pairing') {
|
||||||
|
updateStatus('pairing', 'Board found. Pairing now.');
|
||||||
|
} else if (workerState === 'connected') {
|
||||||
|
connected = true;
|
||||||
|
updateStatus('connected', 'Connected. Waiting for live weight data.');
|
||||||
|
} else if (workerState === 'link-detected') {
|
||||||
|
connected = false;
|
||||||
|
// The native bridge can now distinguish which half of the board's HID
|
||||||
|
// connection reached the server. Preserve that diagnostic until both
|
||||||
|
// channels arrive; the generic text remains for the outbound Sync flow.
|
||||||
|
updateStatus('connecting', message.error || 'Board responded. Reading its sensor calibration.');
|
||||||
|
} else if (workerState === 'connection-failed') {
|
||||||
|
connected = false;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||||
|
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
|
||||||
|
} else if (workerState === 'sleeping') {
|
||||||
|
connected = false;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Board slept during calibration.');
|
||||||
|
updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
|
||||||
|
} else if (workerState === 'waiting') {
|
||||||
|
connected = false;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||||
|
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
|
||||||
|
} else if (workerState === 'error') {
|
||||||
|
connected = false;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
|
||||||
|
updateStatus('error', message.error || 'The Balance Board worker stopped.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
|
||||||
|
socket.join(FRAME_ROOM);
|
||||||
|
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
|
||||||
|
cb({ success: true });
|
||||||
|
});
|
||||||
|
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
|
||||||
|
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startZeroCalibration();
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message || 'Failed to start Balance Board zero calibration' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resetWeightRecord();
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to reset Balance Board weight record', err);
|
||||||
|
cb({ error: err.message || 'Failed to reset the Balance Board weight record' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unpairing) {
|
||||||
|
cb({ error: 'The Balance Board is already being unpaired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unpairing = true;
|
||||||
|
const address = store.address;
|
||||||
|
let bluetoothWarning = '';
|
||||||
|
try {
|
||||||
|
if (address) {
|
||||||
|
try {
|
||||||
|
// A complete forget removes both sources of remembered identity. If
|
||||||
|
// only the JSON address or only the BlueZ bond were removed, the next
|
||||||
|
// red-Sync attempt could inherit half of the previous relationship.
|
||||||
|
await execFileAsync('bluetoothctl', ['remove', address], { timeout: 10000 });
|
||||||
|
} catch (err) {
|
||||||
|
bluetoothWarning = String(
|
||||||
|
err?.stderr || err?.message || 'BlueZ did not remove the bond',
|
||||||
|
).trim();
|
||||||
|
logger.warn('Balance Board BlueZ bond removal failed', bluetoothWarning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.address = '';
|
||||||
|
store.zeroCorners = emptyZeroCorners();
|
||||||
|
store.zeroedAt = null;
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
store.recordKg = 0;
|
||||||
|
store.recordedAt = null;
|
||||||
|
persistStore();
|
||||||
|
clearZeroTimer();
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
connected = false;
|
||||||
|
batteryPercent = null;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
previousWorkerState = '';
|
||||||
|
hardware?.setAddress('');
|
||||||
|
hardware?.restart();
|
||||||
|
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||||
|
sendRawAlert('unpaired');
|
||||||
|
cb({ success: true, warning: bluetoothWarning || null });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to unpair Balance Board', err);
|
||||||
|
cb({ error: err.message || 'Failed to unpair the Balance Board' });
|
||||||
|
} finally {
|
||||||
|
unpairing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
hardware = createBalanceBoardHardware({
|
||||||
|
logger,
|
||||||
|
address: store.address,
|
||||||
|
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||||
|
});
|
||||||
|
hardware.events.on('message', handleWorkerMessage);
|
||||||
|
hardware.start();
|
||||||
|
} else {
|
||||||
|
logger.info('Balance Board disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShutdownHooks() {
|
||||||
|
const shutdown = () => {
|
||||||
|
clearZeroTimer();
|
||||||
|
// A record may still be inside the short debounce window when the process
|
||||||
|
// receives a normal shutdown signal. Flush that newest maximum before the
|
||||||
|
// hardware worker stops so a clean restart cannot lose it.
|
||||||
|
if (recordPersistTimer) {
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
persistStore();
|
||||||
|
}
|
||||||
|
hardware?.stop();
|
||||||
|
};
|
||||||
|
process.once('exit', shutdown);
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
installShutdownHooks();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getState,
|
||||||
|
balanceBoardEvents: events,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CXX ?= g++
|
||||||
|
|
||||||
|
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
|
||||||
|
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
|
||||||
|
# profile: current BlueZ requests medium link security for a bonded board, and
|
||||||
|
# the original Balance Board rejects that negotiation before an input device is
|
||||||
|
# created.
|
||||||
|
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||||
|
LDLIBS += -lwiiuse -lbluetooth -pthread
|
||||||
|
|
||||||
|
TARGET := balance_board_worker
|
||||||
|
SRC := balance_board_worker.cpp
|
||||||
|
|
||||||
|
.PHONY: all clean
|
||||||
|
|
||||||
|
all: $(TARGET)
|
||||||
|
|
||||||
|
$(TARGET): $(SRC)
|
||||||
|
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(TARGET)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('barcodeGameService');
|
const logger = require('../../globals/logger').child('barcodeGameService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
const { sendSystemMessage } = require('../chatService');
|
const { sendSystemMessage } = require('../chatService');
|
||||||
const { getActiveDrivers } = require('../turnService');
|
const { getActiveDrivers } = require('../turnService');
|
||||||
@@ -30,6 +31,7 @@ const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
|
|||||||
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const barcodeGamesConfig = config.barcodeGames || {};
|
const barcodeGamesConfig = config.barcodeGames || {};
|
||||||
|
const enabled = isFeatureEnabled('barcodeGames');
|
||||||
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
|
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
|
||||||
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
|
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
|
||||||
|
|
||||||
@@ -1120,7 +1122,13 @@ function broadcastState() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
if (enabled) {
|
||||||
|
/*
|
||||||
|
Barcode games are an optional layer on top of the physical scanner station.
|
||||||
|
Keep sockets and scan subscriptions behind the feature gate so disabled
|
||||||
|
installs do not run invisible game state.
|
||||||
|
*/
|
||||||
|
io.on('connection', (socket) => {
|
||||||
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
||||||
socket.join(GAME_SOCKET_ROOM);
|
socket.join(GAME_SOCKET_ROOM);
|
||||||
const state = buildStatePayload(socket);
|
const state = buildStatePayload(socket);
|
||||||
@@ -1137,9 +1145,9 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribe('barcode.scanned', (event) => {
|
subscribe('barcode.scanned', (event) => {
|
||||||
try {
|
try {
|
||||||
handleScan(event.payload);
|
handleScan(event.payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1147,7 +1155,10 @@ subscribe('barcode.scanned', (event) => {
|
|||||||
// are logged and skipped so the scanner page can keep resolving barcodes.
|
// are logged and skipped so the scanner page can keep resolving barcodes.
|
||||||
logger.warn('Barcode game scan handling failed', { error: err.message });
|
logger.warn('Barcode game scan handling failed', { error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Barcode games disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildStatePayload,
|
buildStatePayload,
|
||||||
@@ -1155,8 +1166,10 @@ module.exports = {
|
|||||||
setVote,
|
setVote,
|
||||||
};
|
};
|
||||||
|
|
||||||
setInterval(() => {
|
if (enabled) {
|
||||||
|
setInterval(() => {
|
||||||
if (settleActiveGameIfNeeded()) {
|
if (settleActiveGameIfNeeded()) {
|
||||||
broadcastState();
|
broadcastState();
|
||||||
}
|
}
|
||||||
}, GAME_TICK_MS).unref?.();
|
}, GAME_TICK_MS).unref?.();
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('barcodeScannerService');
|
const logger = require('../../globals/logger').child('barcodeScannerService');
|
||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
||||||
@@ -14,6 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
|
|||||||
const RECENT_SCAN_LIMIT = 8;
|
const RECENT_SCAN_LIMIT = 8;
|
||||||
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
|
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
|
||||||
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
|
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
|
||||||
|
const enabled = isFeatureEnabled('barcodeScanner');
|
||||||
|
|
||||||
let lastKnownGoodRegistry = null;
|
let lastKnownGoodRegistry = null;
|
||||||
let lastRegistryError = null;
|
let lastRegistryError = null;
|
||||||
@@ -310,7 +312,12 @@ async function applyScan(rawCode) {
|
|||||||
return { result };
|
return { result };
|
||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
if (enabled) {
|
||||||
|
/*
|
||||||
|
Barcode scanning is tied to a physical scanner station. Disabled installs
|
||||||
|
should not create the registry file or expose scanner socket commands.
|
||||||
|
*/
|
||||||
|
io.on('connection', (socket) => {
|
||||||
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
|
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
|
||||||
socket.join(SCANNER_SOCKET_ROOM);
|
socket.join(SCANNER_SOCKET_ROOM);
|
||||||
socket.emit('barcode:state', buildStatePayload());
|
socket.emit('barcode:state', buildStatePayload());
|
||||||
@@ -329,20 +336,29 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: err.message || 'barcode scan failed' });
|
cb({ error: err.message || 'barcode scan failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modeEvents.on('change', () => {
|
modeEvents.on('change', () => {
|
||||||
// Access-mode changes affect whether the scanner page should beep when it
|
// Access-mode changes affect whether the scanner page should beep when it
|
||||||
// submits a code, so scanner clients need a fresh state packet even without a
|
// submits a code, so scanner clients need a fresh state packet even without a
|
||||||
// new scan.
|
// new scan.
|
||||||
broadcastState();
|
broadcastState();
|
||||||
});
|
});
|
||||||
|
|
||||||
loadRegistryForScan();
|
loadRegistryForScan();
|
||||||
|
} else {
|
||||||
|
logger.info('Barcode scanner disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
REGISTRY_PATH,
|
REGISTRY_PATH,
|
||||||
applyScan,
|
applyScan: (...args) => {
|
||||||
|
if (!enabled) throw new Error('Barcode scanner is disabled');
|
||||||
|
return applyScan(...args);
|
||||||
|
},
|
||||||
buildStatePayload,
|
buildStatePayload,
|
||||||
getRegistrySnapshot,
|
getRegistrySnapshot: () => {
|
||||||
|
if (!enabled) return { registry: null, error: 'barcode scanner disabled' };
|
||||||
|
return getRegistrySnapshot();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('buttonBoxService');
|
const logger = require('../../globals/logger').child('buttonBoxService');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { getRewardById, listRewards } = require('../../rewards');
|
const { getRewardById, listRewards } = require('../../rewards');
|
||||||
@@ -28,6 +29,7 @@ const DATA_DIR = resolveDataDir();
|
|||||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||||
const BUTTON_COUNT = 4;
|
const BUTTON_COUNT = 4;
|
||||||
const STORE_VERSION = 1;
|
const STORE_VERSION = 1;
|
||||||
|
const enabled = isFeatureEnabled('buttonBox');
|
||||||
|
|
||||||
const store = createButtonBoxStore({
|
const store = createButtonBoxStore({
|
||||||
logger,
|
logger,
|
||||||
@@ -62,21 +64,39 @@ const core = createButtonBoxCore({
|
|||||||
store,
|
store,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerButtonBoxRoute({
|
if (enabled) {
|
||||||
|
/*
|
||||||
|
The button box is physical local hardware, so disabled public installs
|
||||||
|
should not expose its LAN-only press endpoint or initialize its reward file.
|
||||||
|
*/
|
||||||
|
registerButtonBoxRoute({
|
||||||
app,
|
app,
|
||||||
logger,
|
logger,
|
||||||
buttonCount: BUTTON_COUNT,
|
buttonCount: BUTTON_COUNT,
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
applyPress: core.applyPress,
|
applyPress: core.applyPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
store.loadState();
|
store.loadState();
|
||||||
core.recoverEffects().catch((err) => {
|
core.recoverEffects().catch((err) => {
|
||||||
logger.warn('Button box effect recovery failed', err.message);
|
logger.warn('Button box effect recovery failed', err.message);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Button box disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getButtonBoxState: store.getStateClone,
|
getButtonBoxState: () => {
|
||||||
addButtonBoxCount: core.addCount,
|
/*
|
||||||
|
Session sync still includes a buttonBox key for a stable payload shape,
|
||||||
|
but disabled mode must not create/read the persisted button-box store.
|
||||||
|
*/
|
||||||
|
if (!enabled) return { buttons: [] };
|
||||||
|
return store.getStateClone();
|
||||||
|
},
|
||||||
|
addButtonBoxCount: (...args) => {
|
||||||
|
if (!enabled) throw new Error('Button box is disabled');
|
||||||
|
return core.addCount(...args);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ const roverManager = require('../roverManager');
|
|||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
const { describeAssignment } = require('../assignmentService');
|
const { describeAssignment } = require('../assignmentService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
|
||||||
|
function resolvePtzChatTarget(socketId) {
|
||||||
|
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRoverId(socketId) {
|
function resolveRoverId(socketId) {
|
||||||
|
const ptzTarget = resolvePtzChatTarget(socketId);
|
||||||
|
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
||||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
const assignment = describeAssignment(socketId);
|
const assignment = describeAssignment(socketId);
|
||||||
@@ -17,13 +24,54 @@ function resolveRoverId(socketId) {
|
|||||||
|
|
||||||
function resolveRoverColor(roverId) {
|
function resolveRoverColor(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
|
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||||
|
return ptzCameraService.getPublicState()?.color || null;
|
||||||
|
}
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.color || null;
|
return record?.meta?.color || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRoverName(roverId) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function isPrivateClosedRoverId(roverId) {
|
function isPrivateClosedRoverId(roverId) {
|
||||||
if (!roverId) return false;
|
if (!roverId) return false;
|
||||||
return roverManager.canReplayRoverId(roverId) !== true;
|
/*
|
||||||
|
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
|
||||||
|
but it is not a private rover. Let PTZ-badged messages broadcast normally
|
||||||
|
instead of falling into the closed-private rover path for unknown ids.
|
||||||
|
*/
|
||||||
|
return !isPublicChatTargetId(roverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileImageUrl(value) {
|
function normalizeProfileImageUrl(value) {
|
||||||
@@ -100,6 +148,7 @@ function buildRoverCtxSnapshot(roverId) {
|
|||||||
function buildMessage(socket, text, meta = {}) {
|
function buildMessage(socket, text, meta = {}) {
|
||||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||||
|
const roverName = meta.roverName ?? resolveRoverName(roverId);
|
||||||
const toolCalls = Array.isArray(meta.toolCalls)
|
const toolCalls = Array.isArray(meta.toolCalls)
|
||||||
? meta.toolCalls
|
? meta.toolCalls
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
@@ -122,6 +171,7 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
roverId,
|
roverId,
|
||||||
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
fromDiscord: Boolean(meta.fromDiscord),
|
fromDiscord: Boolean(meta.fromDiscord),
|
||||||
discordGuildId: meta.discordGuildId || null,
|
discordGuildId: meta.discordGuildId || null,
|
||||||
@@ -143,6 +193,7 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
function buildTypingPayload(socket, meta = {}) {
|
function buildTypingPayload(socket, meta = {}) {
|
||||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||||
|
const roverName = meta.roverName ?? resolveRoverName(roverId);
|
||||||
const socketId = socket?.id || null;
|
const socketId = socket?.id || null;
|
||||||
const fromDiscord = Boolean(meta.fromDiscord);
|
const fromDiscord = Boolean(meta.fromDiscord);
|
||||||
let typingId = meta.typingId || null;
|
let typingId = meta.typingId || null;
|
||||||
@@ -165,6 +216,7 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
roverId,
|
roverId,
|
||||||
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
fromDiscord,
|
fromDiscord,
|
||||||
discordGuildId: meta.discordGuildId || null,
|
discordGuildId: meta.discordGuildId || null,
|
||||||
@@ -179,6 +231,8 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveRoverId,
|
resolveRoverId,
|
||||||
|
isPtzChatTargetId,
|
||||||
|
isPublicChatTargetId,
|
||||||
isPrivateClosedRoverId,
|
isPrivateClosedRoverId,
|
||||||
buildRoverCtxSnapshot,
|
buildRoverCtxSnapshot,
|
||||||
buildMessage,
|
buildMessage,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters
|
|||||||
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
||||||
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
||||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||||
const { runChatTextCommand } = require('./textCommands');
|
const { isTextCommand, runChatTextCommand } = require('./textCommands');
|
||||||
|
|
||||||
function createHandlers({ sendSystemMessage }) {
|
function createHandlers({ sendSystemMessage }) {
|
||||||
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||||
@@ -53,21 +53,26 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
maybeSendAccessNotice(message, sendSystemMessage);
|
maybeSendAccessNotice(message, sendSystemMessage);
|
||||||
maybeSpeak(socket, message, ttsOptions);
|
maybeSpeak(socket, message, ttsOptions);
|
||||||
|
|
||||||
try {
|
const command = isTextCommand(clean);
|
||||||
// Commands sent from site chat should still be visible as normal chat
|
// Chat delivery is complete once validation, broadcast, and local side
|
||||||
// messages. Running the command after broadcast preserves the user-visible
|
// effects above have succeeded. A command may wait on Home Assistant,
|
||||||
// transcript while keeping permissions and command execution entirely on
|
// hardware, replay preparation, or an external transport, so tying the
|
||||||
// the server.
|
// socket acknowledgement to command completion leaves the browser's send
|
||||||
const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
|
// promise pending and makes its input state appear stuck. Acknowledge now;
|
||||||
cb({ success: true, command: ranCommand });
|
// command replies continue through the normal Rover bot message stream.
|
||||||
return;
|
cb({ success: true, command });
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
|
|
||||||
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb({ success: true });
|
if (command) {
|
||||||
|
// Deliberately do not await this promise. runChatTextCommand already turns
|
||||||
|
// ordinary command failures into visible bot messages; this final catch
|
||||||
|
// protects the service from an unexpected setup/programming failure and
|
||||||
|
// cannot attempt a second acknowledgement after the UI has moved on.
|
||||||
|
void runChatTextCommand({ text: clean, socket, sendSystemMessage }).catch((err) => {
|
||||||
|
logger.warn('Chat command failed after acknowledgement', { socket: socket?.id, error: err.message });
|
||||||
|
sendSystemMessage(`Command failed: ${err.message || 'unknown error'}`, { nickname: 'Rover bot', bot: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
|
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { getRole } = require('../roleService');
|
|||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
const { getAdminReason } = require('../adminReasonService');
|
const { getAdminReason } = require('../adminReasonService');
|
||||||
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
const {
|
const {
|
||||||
TYPING_NOTE_DURATION,
|
TYPING_NOTE_DURATION,
|
||||||
ACCESS_NOTICE_COOLDOWN_MS,
|
ACCESS_NOTICE_COOLDOWN_MS,
|
||||||
@@ -18,6 +19,13 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
|||||||
|
|
||||||
function playTypingNote(roverId, note, socketId) {
|
function playTypingNote(roverId, note, socketId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
|
/*
|
||||||
|
PTZ borrows the roverId field for chat badges, but it has no rover command
|
||||||
|
channel. Skipping the song command here keeps PTZ chat from producing noisy
|
||||||
|
"unknown rover" command attempts while still allowing the message itself to
|
||||||
|
behave like rover chat everywhere else.
|
||||||
|
*/
|
||||||
|
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'song',
|
type: 'song',
|
||||||
@@ -83,6 +91,22 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
|||||||
|
|
||||||
function maybeSpeak(socket, message, ttsOptions) {
|
function maybeSpeak(socket, message, ttsOptions) {
|
||||||
if (!ttsOptions || !message?.roverId) return;
|
if (!ttsOptions || !message?.roverId) return;
|
||||||
|
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
place that decides whether a user's message should produce speech, while
|
||||||
|
ptzCameraService owns camera-specific permissions and playback details.
|
||||||
|
*/
|
||||||
|
ptzCameraService.speakText(message.text, ttsOptions, socket)
|
||||||
|
.then(() => {
|
||||||
|
logger.info('PTZ TTS sent', { engine: ttsOptions.engine, socket: socket.id });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn('PTZ TTS send failed', { error: err.message, socket: socket.id });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const record = roverManager.rovers.get(message.roverId);
|
const record = roverManager.rovers.get(message.roverId);
|
||||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const { getActiveDrivers } = require('../turnService');
|
|||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||||
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const liftService = require('../liftService');
|
||||||
|
const neatoService = require('../neatoService');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const {
|
const {
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
@@ -19,20 +23,21 @@ const {
|
|||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { createCommandHandlers } = require('../discordBotService/commands');
|
const { createCommandHandlers } = require('../operatorCommandService');
|
||||||
|
const { parseCommandText } = require('../operatorCommandService/config');
|
||||||
|
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
|
||||||
const { commandReplyToText } = require('./commandResultFormatter');
|
const { commandReplyToText } = require('./commandResultFormatter');
|
||||||
const {
|
const {
|
||||||
buildReplayJobId,
|
buildReplayJobId,
|
||||||
buildReplayTitle,
|
buildReplayTitle,
|
||||||
createReplaySourceResolver,
|
createReplaySourceResolver,
|
||||||
} = require('../discordBotService/replayWorkflow');
|
} = require('../replayDeliveryService/workflow');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
|
|
||||||
function isTextCommand(text) {
|
function isTextCommand(text) {
|
||||||
const clean = String(text || '').trim();
|
return parseCommandText(text, config).matched;
|
||||||
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeMentions(text) {
|
function sanitizeMentions(text) {
|
||||||
@@ -69,12 +74,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelId = discordConfig?.channels?.replay || null;
|
|
||||||
if (!channelId) {
|
|
||||||
await message.reply({ content: 'Replay denied: replay channel is not configured.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = sourceResolver.resolve(query);
|
const resolved = sourceResolver.resolve(query);
|
||||||
if (resolved?.error) {
|
if (resolved?.error) {
|
||||||
await message.reply({ content: resolved.error });
|
await message.reply({ content: resolved.error });
|
||||||
@@ -98,7 +97,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
|||||||
type: 'replay.requested',
|
type: 'replay.requested',
|
||||||
payload: {
|
payload: {
|
||||||
jobId,
|
jobId,
|
||||||
channelId,
|
|
||||||
requester,
|
requester,
|
||||||
title: '',
|
title: '',
|
||||||
includeSidebar: true,
|
includeSidebar: true,
|
||||||
@@ -112,18 +110,18 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChatCommandMessage({ socket, text, sendSystemMessage }) {
|
function createChatCommandRequest({ socket, text, sendSystemMessage }) {
|
||||||
const nickname = buildRequesterLabel(socket);
|
const nickname = buildRequesterLabel(socket);
|
||||||
return {
|
return {
|
||||||
content: String(text || '').trim(),
|
content: String(text || '').trim(),
|
||||||
author: {
|
actor: {
|
||||||
bot: false,
|
bot: false,
|
||||||
id: socket.id,
|
id: socket.id,
|
||||||
username: nickname,
|
label: nickname,
|
||||||
},
|
isAdmin: isAdmin(socket),
|
||||||
member: {
|
isLockdownAdmin: isLockdownAdmin(socket),
|
||||||
nickname,
|
|
||||||
},
|
},
|
||||||
|
transport: 'web-chat',
|
||||||
reply: async (payload) => {
|
reply: async (payload) => {
|
||||||
const response = sanitizeMentions(commandReplyToText(payload));
|
const response = sanitizeMentions(commandReplyToText(payload));
|
||||||
if (!response) return null;
|
if (!response) return null;
|
||||||
@@ -138,8 +136,8 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
// keeps ordinary chatService initialization from changing the service boot
|
// keeps ordinary chatService initialization from changing the service boot
|
||||||
// order, while still letting `rs replay` use the existing replay pipeline.
|
// order, while still letting `rs replay` use the existing replay pipeline.
|
||||||
const replayApi = require('../replayEngineV2');
|
const replayApi = require('../replayEngineV2');
|
||||||
const message = createChatCommandMessage({ socket, text, sendSystemMessage });
|
const message = createChatCommandRequest({ socket, text, sendSystemMessage });
|
||||||
const commands = createCommandHandlers({
|
const commandDependencies = {
|
||||||
logger: null,
|
logger: null,
|
||||||
client: null,
|
client: null,
|
||||||
io,
|
io,
|
||||||
@@ -162,6 +160,14 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
getAdminReason,
|
getAdminReason,
|
||||||
setAdminReason,
|
setAdminReason,
|
||||||
clearAdminReason,
|
clearAdminReason,
|
||||||
|
// Web chat builds its own command-router instance for the sending socket.
|
||||||
|
// Supplying the same Home Assistant service used by Discord keeps `rs
|
||||||
|
// lights lock/unlock` from becoming transport-specific, and it preserves
|
||||||
|
// the existing session update path for all connected browsers.
|
||||||
|
homeAssistantService,
|
||||||
|
liftService,
|
||||||
|
neatoService,
|
||||||
|
isFeatureEnabled,
|
||||||
getGuildConfig: () => null,
|
getGuildConfig: () => null,
|
||||||
setGuildConfig: () => null,
|
setGuildConfig: () => null,
|
||||||
removeGuildConfig: () => null,
|
removeGuildConfig: () => null,
|
||||||
@@ -177,9 +183,12 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
|
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
|
||||||
discordConfig,
|
discordConfig,
|
||||||
|
siteUrl: String(discordConfig.siteUrl || ''),
|
||||||
config,
|
config,
|
||||||
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
|
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
|
||||||
});
|
};
|
||||||
|
commandDependencies.transportHandlers = createWebTransportHandlers(commandDependencies);
|
||||||
|
const commands = createCommandHandlers(commandDependencies);
|
||||||
|
|
||||||
// Let the shared router perform normal command permission checks. Site chat
|
// Let the shared router perform normal command permission checks. Site chat
|
||||||
// has already broadcast the user's command text, so command replies become a
|
// has already broadcast the user's command text, so command replies become a
|
||||||
|
|||||||
@@ -106,6 +106,28 @@ function handleAck(msg) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function issueUpdateToAllRovers() {
|
||||||
|
const updated = [];
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
roverManager.rovers.forEach((record) => {
|
||||||
|
if (!record?.ws) return;
|
||||||
|
|
||||||
|
const roverId = String(record.id);
|
||||||
|
try {
|
||||||
|
// Use the same narrow update payload as the per-rover admin action. The
|
||||||
|
// browser only asks for "update all"; the Pi still owns the privileged
|
||||||
|
// pull/install/reboot sequence through its fixed self-update helper.
|
||||||
|
issueCommand(roverId, { type: 'update', update: {} });
|
||||||
|
updated.push(roverId);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({ roverId, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { updated, failed };
|
||||||
|
}
|
||||||
|
|
||||||
function getRecentDriveActivity(windowMs, options = {}) {
|
function getRecentDriveActivity(windowMs, options = {}) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -288,6 +310,26 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('command', handleCommand);
|
socket.on('command', handleCommand);
|
||||||
socket.on('command:issue', handleCommand);
|
socket.on('command:issue', handleCommand);
|
||||||
|
|
||||||
|
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
|
||||||
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
|
try {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
throw new Error('Not authorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = issueUpdateToAllRovers();
|
||||||
|
logger.warn('Admin requested update for all online rovers', {
|
||||||
|
socketId: socket.id,
|
||||||
|
updated: result.updated,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
reply(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Update-all rovers rejected', socket.id, err.message);
|
||||||
|
reply({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
homeAssistantService.homeAssistantEvents.on('update', () => {
|
homeAssistantService.homeAssistantEvents.on('update', () => {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Discord Command Adapter
|
||||||
|
// Purpose: Supplies Discord-specific renderers and extension commands to the operator command service.
|
||||||
|
// Scope: Keeps Discord embeds, attachments, guild permissions, and bridge context outside the shared command core.
|
||||||
|
const { createStatusCommand } = require('./commands/status');
|
||||||
|
const { createReplayCommand } = require('./commands/replay');
|
||||||
|
const { createBridgeCommand } = require('./commands/bridge');
|
||||||
|
const { createTimeStatusCommand } = require('./commands/timeStatus');
|
||||||
|
|
||||||
|
function createDiscordTransportHandlers(deps) {
|
||||||
|
const status = createStatusCommand(deps);
|
||||||
|
const replay = createReplayCommand(deps);
|
||||||
|
const bridge = createBridgeCommand(deps);
|
||||||
|
const timeStatus = createTimeStatusCommand(deps);
|
||||||
|
return {
|
||||||
|
status: (request, query) => status(request.context.discordMessage, query),
|
||||||
|
replay: (request, query) => replay(request.context.discordMessage, query),
|
||||||
|
bridge: (request, tokens) => bridge(request.context.discordMessage, tokens),
|
||||||
|
timeStatus: (request) => timeStatus(request.context.discordMessage),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDiscordCommandRequest(message, { isAdminUser, isLockdownAdminUser }) {
|
||||||
|
const id = message.author?.id || null;
|
||||||
|
return {
|
||||||
|
content: String(message.content || ''),
|
||||||
|
transport: 'discord',
|
||||||
|
actor: {
|
||||||
|
id,
|
||||||
|
label: message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord',
|
||||||
|
bot: Boolean(message.author?.bot),
|
||||||
|
isAdmin: isAdminUser(id),
|
||||||
|
isLockdownAdmin: isLockdownAdminUser(id),
|
||||||
|
},
|
||||||
|
reply: (payload) => message.reply(payload),
|
||||||
|
context: { discordMessage: message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDiscordTransportHandlers, createDiscordCommandRequest };
|
||||||
@@ -2,8 +2,12 @@
|
|||||||
// Purpose: Handles chat bridge configuration/status commands per guild.
|
// Purpose: Handles chat bridge configuration/status commands per guild.
|
||||||
// Scope: Manages bridge channel, mode, and webhook provisioning.
|
// Scope: Manages bridge channel, mode, and webhook provisioning.
|
||||||
const { PermissionsBitField } = require('discord.js');
|
const { PermissionsBitField } = require('discord.js');
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser }) {
|
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, config }) {
|
||||||
|
// Error text should name the active prefix because bridge setup is one of the
|
||||||
|
// first commands an admin runs when a bot instance joins a shared Discord.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
function canManageBridge(message) {
|
function canManageBridge(message) {
|
||||||
if (isAdminUser(message.author.id)) return true;
|
if (isAdminUser(message.author.id)) return true;
|
||||||
if (!message.guild || !message.member) return false;
|
if (!message.guild || !message.member) return false;
|
||||||
@@ -45,7 +49,7 @@ function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig
|
|||||||
|
|
||||||
// Every command below this point mutates the guild bridge configuration.
|
// Every command below this point mutates the guild bridge configuration.
|
||||||
// Keeping the authorization check in one shared gate prevents destructive
|
// Keeping the authorization check in one shared gate prevents destructive
|
||||||
// actions, especially `rs bridge off`, from accidentally bypassing the same
|
// actions, especially bridge disable, from accidentally bypassing the same
|
||||||
// Manage Server/admin requirement used by `here` and `mode`.
|
// Manage Server/admin requirement used by `here` and `mode`.
|
||||||
if (!canManageBridge(message)) return message.reply({ content: 'You need Manage Server permissions to change the chat bridge.', allowedMentions: { parse: [], repliedUser: false } });
|
if (!canManageBridge(message)) return message.reply({ content: 'You need Manage Server permissions to change the chat bridge.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
|
||||||
@@ -64,14 +68,14 @@ function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig
|
|||||||
|
|
||||||
if (action === 'mode') {
|
if (action === 'mode') {
|
||||||
const current = getGuildConfig(guildId);
|
const current = getGuildConfig(guildId);
|
||||||
if (!current?.channelId) return message.reply({ content: 'No chat bridge channel set. Use `rs bridge here <global|private>` first.', allowedMentions: { parse: [], repliedUser: false } });
|
if (!current?.channelId) return message.reply({ content: `No chat bridge channel set. Use \`${commandPrefix} bridge here <global|private>\` first.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
const nextMode = normalizeMode(mode, null);
|
const nextMode = normalizeMode(mode, null);
|
||||||
if (!VALID_MODES.has(nextMode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } });
|
if (!VALID_MODES.has(nextMode)) return message.reply({ content: 'Invalid mode. Use `global` or `private`.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
const entry = setGuildConfig(guildId, { channelId: current.channelId, mode: nextMode, webhookId: current.webhookId, webhookToken: current.webhookToken });
|
const entry = setGuildConfig(guildId, { channelId: current.channelId, mode: nextMode, webhookId: current.webhookId, webhookToken: current.webhookToken });
|
||||||
return message.reply({ content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: `Chat bridge mode updated to **${entry.mode}** in <#${entry.channelId}>.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return message.reply({ content: 'Unknown bridge command. Try `rs bridge`.', allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: `Unknown bridge command. Try \`${commandPrefix} bridge\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
// Discord Help Command
|
|
||||||
// Purpose: Provides help text for rover bot Discord commands.
|
|
||||||
// Scope: Returns static command usage text.
|
|
||||||
function formatHelp() {
|
|
||||||
return [
|
|
||||||
'**Rover Bot Commands**',
|
|
||||||
'`rs help` — show this help',
|
|
||||||
'`rs status [rover]` — show rover status; rover names can be fuzzy',
|
|
||||||
'`rs replay [sources]` — send instant replay; source names can be fuzzy',
|
|
||||||
'`rs bridge` — show chat bridge status for this server',
|
|
||||||
'`rs bridge here <global|private>` — set chat bridge to this channel',
|
|
||||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
|
||||||
'`rs bridge off` — disable chat bridge for this server',
|
|
||||||
'`rs lock <rover>` — lock a rover; rover names can be fuzzy',
|
|
||||||
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
|
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
|
||||||
'`rs reason [text|clear]` — show or set admin mode reason',
|
|
||||||
'`rs goal [text|clear]` — show or set global objective',
|
|
||||||
'`rs verify list` — list verified users (lockdown admins)',
|
|
||||||
'`rs verify remove <cookieUserId|nickname>` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)',
|
|
||||||
'`rs deter list` — list deterred users (lockdown admins)',
|
|
||||||
'`rs deter ban <cookieUserId|nickname|ip>` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)',
|
|
||||||
'`rs deter unban <id|cookieUserId|nickname|ip>` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)',
|
|
||||||
'`ts` — show time status',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { formatHelp };
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// Discord Commands Router
|
|
||||||
// Purpose: Routes incoming Discord command messages to one-file-per-command handlers.
|
|
||||||
// Scope: Central command dispatcher and permission gate orchestration.
|
|
||||||
const { formatHelp } = require('./help');
|
|
||||||
const { createStatusCommand } = require('./status');
|
|
||||||
const { createReplayCommand } = require('./replay');
|
|
||||||
const { createLockCommand } = require('./lock');
|
|
||||||
const { createModeCommand } = require('./mode');
|
|
||||||
const { createReasonCommand } = require('./reason');
|
|
||||||
const { createGoalCommand } = require('./goal');
|
|
||||||
const { createVerifyCommand } = require('./verify');
|
|
||||||
const { createDeterCommand } = require('./deter');
|
|
||||||
const { createBridgeCommand } = require('./bridge');
|
|
||||||
const { createTimeStatusCommand } = require('./timeStatus');
|
|
||||||
|
|
||||||
function createCommandHandlers(deps) {
|
|
||||||
const {
|
|
||||||
getMode,
|
|
||||||
MODES,
|
|
||||||
isAdminUser,
|
|
||||||
isLockdownAdminUser,
|
|
||||||
} = deps;
|
|
||||||
|
|
||||||
const handleStatusCommand = createStatusCommand(deps);
|
|
||||||
const handleReplayCommand = deps.createReplayTextCommand
|
|
||||||
? deps.createReplayTextCommand(deps)
|
|
||||||
: createReplayCommand(deps);
|
|
||||||
const handleLockCommand = createLockCommand(deps);
|
|
||||||
const handleModeCommand = createModeCommand(deps);
|
|
||||||
const handleReasonCommand = createReasonCommand(deps);
|
|
||||||
const handleGoalCommand = createGoalCommand(deps);
|
|
||||||
const handleVerifyCommand = createVerifyCommand(deps);
|
|
||||||
const handleDeterCommand = createDeterCommand(deps);
|
|
||||||
const handleBridgeCommand = createBridgeCommand(deps);
|
|
||||||
const handleTimeStatusCommand = createTimeStatusCommand(deps);
|
|
||||||
|
|
||||||
async function handleCommand(message) {
|
|
||||||
if (message.author.bot) return;
|
|
||||||
const content = (message.content || '').trim();
|
|
||||||
const lower = content.toLowerCase();
|
|
||||||
// Commands are intentionally matched as whole prefixes. The previous
|
|
||||||
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
|
|
||||||
// like commands, which is especially bad now that web chat will run the
|
|
||||||
// same server-side dispatcher before broadcasting user text.
|
|
||||||
if (lower === 'ts') return handleTimeStatusCommand(message);
|
|
||||||
if (!/^rs(?:\s|$)/i.test(content)) return;
|
|
||||||
|
|
||||||
const tokens = content.split(/\s+/);
|
|
||||||
tokens.shift();
|
|
||||||
const action = (tokens.shift() || '').toLowerCase();
|
|
||||||
const rest = tokens.join(' ').trim();
|
|
||||||
const isAdmin = isAdminUser(message.author.id);
|
|
||||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
|
||||||
const mode = getMode();
|
|
||||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']);
|
|
||||||
|
|
||||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
|
||||||
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
|
|
||||||
await message.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case '':
|
|
||||||
case 'status':
|
|
||||||
return handleStatusCommand(message, rest);
|
|
||||||
case 'help':
|
|
||||||
return message.reply(formatHelp());
|
|
||||||
case 'replay':
|
|
||||||
return handleReplayCommand(message, tokens.join(' '));
|
|
||||||
case 'bridge':
|
|
||||||
return handleBridgeCommand(message, tokens);
|
|
||||||
case 'lock':
|
|
||||||
return handleLockCommand(message, rest, true);
|
|
||||||
case 'unlock':
|
|
||||||
return handleLockCommand(message, rest, false);
|
|
||||||
case 'mode':
|
|
||||||
return handleModeCommand(message, tokens);
|
|
||||||
case 'goal':
|
|
||||||
return handleGoalCommand(message, tokens);
|
|
||||||
case 'reason':
|
|
||||||
return handleReasonCommand(message, tokens);
|
|
||||||
case 'verify':
|
|
||||||
return handleVerifyCommand(message, tokens);
|
|
||||||
case 'deter':
|
|
||||||
return handleDeterCommand(message, tokens);
|
|
||||||
default:
|
|
||||||
return message.reply(formatHelp());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handleCommand };
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createCommandHandlers };
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
|
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
|
||||||
const { AttachmentBuilder } = require('discord.js');
|
const { AttachmentBuilder } = require('discord.js');
|
||||||
const io = require('../../../globals/io');
|
const io = require('../../../globals/io');
|
||||||
|
const { hostReplay } = require('../../replayMediaService');
|
||||||
const {
|
const {
|
||||||
DEFAULT_ALLOWED_MENTIONS,
|
DEFAULT_ALLOWED_MENTIONS,
|
||||||
buildReplayJobId,
|
buildReplayJobId,
|
||||||
@@ -17,7 +18,7 @@ const {
|
|||||||
buildAcceptedMessage,
|
buildAcceptedMessage,
|
||||||
buildStatusMessage,
|
buildStatusMessage,
|
||||||
normalizeUserError,
|
normalizeUserError,
|
||||||
} = require('../replayWorkflow');
|
} = require('../../replayDeliveryService/workflow');
|
||||||
|
|
||||||
function createReplayCommand({
|
function createReplayCommand({
|
||||||
logger,
|
logger,
|
||||||
@@ -32,6 +33,7 @@ function createReplayCommand({
|
|||||||
getActiveDrivers,
|
getActiveDrivers,
|
||||||
getNickname,
|
getNickname,
|
||||||
rovers,
|
rovers,
|
||||||
|
discordConfig,
|
||||||
}) {
|
}) {
|
||||||
const sourceResolver = createReplaySourceResolver({
|
const sourceResolver = createReplaySourceResolver({
|
||||||
rovers,
|
rovers,
|
||||||
@@ -80,18 +82,21 @@ function createReplayCommand({
|
|||||||
});
|
});
|
||||||
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||||
|
|
||||||
|
let builtReplay = null;
|
||||||
|
let deliveredMedia = null;
|
||||||
try {
|
try {
|
||||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
|
builtReplay = await buildReplayVideo({
|
||||||
sources: job.sources,
|
sources: job.sources,
|
||||||
title: job.title,
|
title: job.title,
|
||||||
requester: job.requester,
|
requester: job.requester,
|
||||||
includeSidebar: job.includeSidebar,
|
includeSidebar: job.includeSidebar,
|
||||||
});
|
});
|
||||||
|
const { buffer, usedSources = job.sources, missingSources = [] } = builtReplay;
|
||||||
|
|
||||||
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
@@ -108,14 +113,36 @@ function createReplayCommand({
|
|||||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||||
|
|
||||||
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
||||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
deliveredMedia = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
||||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
if (!deliveredMedia) throw new Error('Discord upload did not include a replay attachment URL');
|
||||||
|
|
||||||
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media: deliveredMedia });
|
||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (deliveredMedia) {
|
||||||
|
logger?.warn?.('Replay uploaded but Discord progress message could not be finalized', { jobId: job.id, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A completed video should never be discarded merely because the
|
||||||
|
// optional Discord upload failed. Host that exact buffer locally and
|
||||||
|
// publish the same ready event consumed by existing clients.
|
||||||
|
if (builtReplay?.buffer && !deliveredMedia) {
|
||||||
|
try {
|
||||||
|
const media = await hostReplay({ buffer: builtReplay.buffer, job });
|
||||||
|
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
||||||
|
if (progressMessage?.edit) {
|
||||||
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
|
}
|
||||||
|
const siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, '');
|
||||||
|
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
|
||||||
|
await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
|
return;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
logger?.warn?.('Local replay fallback failed', { jobId: job.id, error: fallbackError.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
const userMessage = normalizeUserError(err);
|
const userMessage = normalizeUserError(err);
|
||||||
jobStatus.emit(job, 'failed', { message: userMessage });
|
jobStatus.emit(job, 'failed', { message: userMessage });
|
||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
|
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
|
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
|
||||||
const { resolveRoverSelector } = require('./resolvers');
|
const { resolveRoverSelector } = require('../../operatorCommandService/commands/resolvers');
|
||||||
|
|
||||||
function createStatusCommand({ rovers, roverManager }) {
|
function createStatusCommand({ rovers, roverManager }) {
|
||||||
return async function handleStatusCommand(message, roverId) {
|
return async function handleStatusCommand(message, roverId) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Discord Time Status Command
|
// Discord Time Status Command
|
||||||
// Purpose: Handles `ts` command to show timezone snapshots.
|
// Purpose: Handles the configured time-status shortcut to show timezone snapshots.
|
||||||
// Scope: Builds a concise time embed for common zones and server local zone.
|
// Scope: Builds a concise time embed for common zones and server local zone.
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ const {
|
|||||||
Client,
|
Client,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
Partials,
|
Partials,
|
||||||
|
AttachmentBuilder,
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const logger = require('../../globals/logger').child('discordBot');
|
const logger = require('../../globals/logger').child('discordBot');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
|
const { parseCommandText } = require('../operatorCommandService/config');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRoster, lockRover, rovers } = roverManager;
|
const { getRoster, lockRover, rovers } = roverManager;
|
||||||
const { MODES, getMode, setMode } = require('../modeManager');
|
const { MODES, getMode, setMode } = require('../modeManager');
|
||||||
@@ -19,6 +22,9 @@ const { getActiveDrivers } = require('../turnService');
|
|||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||||
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const liftService = require('../liftService');
|
||||||
|
const neatoService = require('../neatoService');
|
||||||
const {
|
const {
|
||||||
getGuildConfig,
|
getGuildConfig,
|
||||||
listGuildConfigs,
|
listGuildConfigs,
|
||||||
@@ -47,17 +53,32 @@ const {
|
|||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
const { createPresenceManager } = require('./presence');
|
const { createPresenceManager } = require('./presence');
|
||||||
const { createChannelIO } = require('./channelIO');
|
const { createChannelIO } = require('./channelIO');
|
||||||
const { createCommandHandlers } = require('./commands');
|
const { createCommandHandlers } = require('../operatorCommandService');
|
||||||
|
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
|
||||||
const { createIntegrations } = require('./integrations');
|
const { createIntegrations } = require('./integrations');
|
||||||
|
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
|
||||||
|
const {
|
||||||
|
DEFAULT_ALLOWED_MENTIONS,
|
||||||
|
createReplayCaptionBuilder,
|
||||||
|
startDiscordTypingLoop,
|
||||||
|
sanitizeReplayTitleForFilename,
|
||||||
|
firstAttachmentFromMessage,
|
||||||
|
buildDiscordReplayMediaPayload,
|
||||||
|
buildAcceptedMessage,
|
||||||
|
buildStatusMessage,
|
||||||
|
} = require('../replayDeliveryService/workflow');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
const enabled = Boolean(discordConfig.token);
|
const enabled = isFeatureEnabled('discord');
|
||||||
|
// These normalized command names mirror the command router. Bridge-channel
|
||||||
|
// command replies are mirrored into web chat, so this entrypoint needs to know
|
||||||
|
// the configured command names before it wraps message.reply.
|
||||||
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
|
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
|
||||||
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
|
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
logger.info('Discord bot disabled; missing token in config.discord.token');
|
logger.info('Discord feature disabled or missing required token');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +134,72 @@ const presence = createPresenceManager({
|
|||||||
countReady,
|
countReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commands = createCommandHandlers({
|
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.
|
||||||
|
if (discordConfig?.channels?.replay) {
|
||||||
|
registerPreferredDeliveryProvider({
|
||||||
|
async begin(job) {
|
||||||
|
const channelId = discordConfig.channels.replay;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async completeFallback({ context, media }) {
|
||||||
|
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, '');
|
||||||
|
const publicUrl = siteUrl ? `${siteUrl}${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,
|
logger,
|
||||||
client,
|
client,
|
||||||
io,
|
io,
|
||||||
@@ -136,6 +222,14 @@ const commands = createCommandHandlers({
|
|||||||
getAdminReason,
|
getAdminReason,
|
||||||
setAdminReason,
|
setAdminReason,
|
||||||
clearAdminReason,
|
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,
|
||||||
|
liftService,
|
||||||
|
neatoService,
|
||||||
|
isFeatureEnabled,
|
||||||
getGuildConfig,
|
getGuildConfig,
|
||||||
setGuildConfig,
|
setGuildConfig,
|
||||||
removeGuildConfig,
|
removeGuildConfig,
|
||||||
@@ -152,7 +246,9 @@ const commands = createCommandHandlers({
|
|||||||
isLockdownAdminUser,
|
isLockdownAdminUser,
|
||||||
discordConfig,
|
discordConfig,
|
||||||
config,
|
config,
|
||||||
});
|
};
|
||||||
|
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
|
||||||
|
const commands = createCommandHandlers(commandDependencies);
|
||||||
|
|
||||||
const integrations = createIntegrations({
|
const integrations = createIntegrations({
|
||||||
logger,
|
logger,
|
||||||
@@ -194,8 +290,9 @@ const commands = createCommandHandlers({
|
|||||||
const integrationHandlers = integrations.register();
|
const integrationHandlers = integrations.register();
|
||||||
|
|
||||||
function isTextCommand(content) {
|
function isTextCommand(content) {
|
||||||
const clean = String(content || '').trim();
|
// Both transports share this parser so command detection cannot drift from
|
||||||
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
|
// the dispatcher when an installation changes its prefix.
|
||||||
|
return parseCommandText(content, config).matched;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBridgeChannelMessage(message) {
|
function isBridgeChannelMessage(message) {
|
||||||
@@ -240,7 +337,8 @@ function createBridgeMirroredCommandMessage(message) {
|
|||||||
client.on('messageCreate', async (message) => {
|
client.on('messageCreate', async (message) => {
|
||||||
try {
|
try {
|
||||||
await integrationHandlers.handleBridgeInbound(message);
|
await integrationHandlers.handleBridgeInbound(message);
|
||||||
await commands.handleCommand(createBridgeMirroredCommandMessage(message));
|
const commandMessage = createBridgeMirroredCommandMessage(message);
|
||||||
|
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Error handling Discord message', err.message);
|
logger.warn('Error handling Discord message', err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,12 @@
|
|||||||
// Purpose: Handles event-bus announcements to Discord channels.
|
// Purpose: Handles event-bus announcements to Discord channels.
|
||||||
// Scope: Processes supported event types and posts formatted messages/embeds.
|
// Scope: Processes supported event types and posts formatted messages/embeds.
|
||||||
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
||||||
const io = require('../../../globals/io');
|
|
||||||
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
|
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
|
||||||
const {
|
|
||||||
DEFAULT_ALLOWED_MENTIONS,
|
|
||||||
createReplayJob,
|
|
||||||
createJobStatusEmitter,
|
|
||||||
createReplayCaptionBuilder,
|
|
||||||
startDiscordTypingLoop,
|
|
||||||
sanitizeReplayTitleForFilename,
|
|
||||||
firstAttachmentFromMessage,
|
|
||||||
buildDiscordReplayMediaPayload,
|
|
||||||
buildAcceptedMessage,
|
|
||||||
buildStatusMessage,
|
|
||||||
normalizeUserError,
|
|
||||||
} = require('../replayWorkflow');
|
|
||||||
|
|
||||||
function createBusEventHandler(deps) {
|
function createBusEventHandler(deps) {
|
||||||
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, fetchChannel, buildReplayVideo, getActiveDrivers, getNickname, sanitizeMentions } = deps;
|
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
|
||||||
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
|
||||||
let skippedFirstModeAnnouncement = false;
|
let skippedFirstModeAnnouncement = false;
|
||||||
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
|
|
||||||
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
|
|
||||||
|
|
||||||
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
|
||||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
||||||
@@ -55,66 +39,6 @@ function createBusEventHandler(deps) {
|
|||||||
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
|
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true, jobId = null, requestedBy = null) {
|
|
||||||
if (!channelId) throw new Error('Replay channel not configured');
|
|
||||||
const job = createReplayJob({
|
|
||||||
id: jobId,
|
|
||||||
requester,
|
|
||||||
source: 'web',
|
|
||||||
title: explicitTitle,
|
|
||||||
sources,
|
|
||||||
includeSidebar,
|
|
||||||
requestedBy,
|
|
||||||
});
|
|
||||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
|
||||||
const progressMessage = await sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
|
|
||||||
const channel = await fetchChannel(channelId);
|
|
||||||
const stopTyping = startDiscordTypingLoop(channel, logger, 'web replay delivery');
|
|
||||||
try {
|
|
||||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
|
||||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
||||||
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
|
|
||||||
sources: job.sources,
|
|
||||||
title: job.title,
|
|
||||||
requester: job.requester,
|
|
||||||
includeSidebar: job.includeSidebar,
|
|
||||||
});
|
|
||||||
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
|
||||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
|
||||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
|
||||||
const uploadMessage = await sendToChannel(channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
|
||||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
|
||||||
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
|
|
||||||
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
|
|
||||||
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
|
|
||||||
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
|
||||||
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
||||||
} catch (err) {
|
|
||||||
const message = normalizeUserError(err);
|
|
||||||
jobStatus.emit(job, 'failed', { message });
|
|
||||||
if (progressMessage?.edit) await progressMessage.edit({ content: sanitizeMentions(message), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
stopTyping();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReplayRequested(event) {
|
|
||||||
const payload = event?.payload || {};
|
|
||||||
sendReplayToChannel(
|
|
||||||
payload?.channelId,
|
|
||||||
payload?.requester,
|
|
||||||
payload?.sources || [],
|
|
||||||
payload?.title || '',
|
|
||||||
payload?.includeSidebar !== false,
|
|
||||||
payload?.jobId || null,
|
|
||||||
payload?.requestedBy || null,
|
|
||||||
).catch((err) => {
|
|
||||||
logger.warn('Replay send failed', { error: err.message });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBusEvent(event) {
|
function handleBusEvent(event) {
|
||||||
const { type, payload } = event || {};
|
const { type, payload } = event || {};
|
||||||
const channels = discordConfig.channels || {};
|
const channels = discordConfig.channels || {};
|
||||||
@@ -200,9 +124,9 @@ function createBusEventHandler(deps) {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'replay.requested':
|
// Replay requests are deliberately consumed by replayDeliveryService.
|
||||||
handleReplayRequested(event);
|
// Discord registers only a preferred delivery provider, allowing the
|
||||||
break;
|
// same request to fall back locally without a second event subscriber.
|
||||||
case 'buttonBox.discordStalkerPing': {
|
case 'buttonBox.discordStalkerPing': {
|
||||||
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
|
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
|
||||||
announce({
|
announce({
|
||||||
@@ -234,7 +158,7 @@ function createBusEventHandler(deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { handleBusEvent, handleReplayRequested };
|
return { handleBusEvent };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createBusEventHandler };
|
module.exports = { createBusEventHandler };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Bridges chat and typing between Discord and site sockets.
|
// Purpose: Bridges chat and typing between Discord and site sockets.
|
||||||
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
|
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
|
||||||
const { WebhookClient } = require('discord.js');
|
const { WebhookClient } = require('discord.js');
|
||||||
|
const { isPublicChatTargetId } = require('../../chatService/contextBuilders');
|
||||||
|
|
||||||
function summarizeToolCall(entry = {}) {
|
function summarizeToolCall(entry = {}) {
|
||||||
const tool = String(entry?.tool || 'unknown');
|
const tool = String(entry?.tool || 'unknown');
|
||||||
@@ -23,7 +24,6 @@ function createChatBridgeHandlers(deps) {
|
|||||||
const {
|
const {
|
||||||
logger,
|
logger,
|
||||||
client,
|
client,
|
||||||
roverManager,
|
|
||||||
getGuildConfig,
|
getGuildConfig,
|
||||||
listGuildConfigs,
|
listGuildConfigs,
|
||||||
sendExternalMessage,
|
sendExternalMessage,
|
||||||
@@ -59,7 +59,13 @@ function createChatBridgeHandlers(deps) {
|
|||||||
function handleChatBridgeOutbound(event) {
|
function handleChatBridgeOutbound(event) {
|
||||||
const payload = event?.payload;
|
const payload = event?.payload;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) 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();
|
const guildConfigs = listGuildConfigs();
|
||||||
if (!guildConfigs.length) return;
|
if (!guildConfigs.length) return;
|
||||||
|
|
||||||
@@ -96,7 +102,11 @@ function createChatBridgeHandlers(deps) {
|
|||||||
function handleChatTypingOutbound(event) {
|
function handleChatTypingOutbound(event) {
|
||||||
const payload = event?.payload;
|
const payload = event?.payload;
|
||||||
if (!payload || payload.fromDiscord) return;
|
if (!payload || payload.fromDiscord) return;
|
||||||
if (payload?.roverId && !roverManager.canReplayRoverId(payload.roverId)) return;
|
/*
|
||||||
|
Typing indicators follow the same public-chat-target rule as messages so
|
||||||
|
PTZ users do not look present in web chat while disappearing from Discord.
|
||||||
|
*/
|
||||||
|
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
||||||
const guildConfigs = listGuildConfigs();
|
const guildConfigs = listGuildConfigs();
|
||||||
if (!guildConfigs.length) return;
|
if (!guildConfigs.length) return;
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ function formatWebhookUsername(payload) {
|
|||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||||
}
|
}
|
||||||
const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
|
/*
|
||||||
|
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}]` : '';
|
||||||
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@
|
|||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const { renderIndexHtml, renderOgImage } = require('../embedService');
|
const { renderIndexHtml, renderOgImage } = require('../embedService');
|
||||||
|
|
||||||
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => {
|
/*
|
||||||
|
Every client-side BrowserRouter entry point must also be an explicit HTTP
|
||||||
|
entry point. Including /ptz here lets direct loads and browser refreshes
|
||||||
|
receive the same rendered index document as navigation from the driver page.
|
||||||
|
*/
|
||||||
|
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz'], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const html = await renderIndexHtml(req);
|
const html = await renderIndexHtml(req);
|
||||||
res.type('html').send(html);
|
res.type('html').send(html);
|
||||||
|
|||||||
@@ -35,11 +35,26 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBlockedByRoomControlLock() {
|
||||||
|
/*
|
||||||
|
The lock is meant to keep normal users and automated room-control
|
||||||
|
surfaces from changing the preferred room-light policy. Admins are the
|
||||||
|
exception because they may need to correct a single lamp, verify a Home
|
||||||
|
Assistant integration, or make an operational adjustment while the
|
||||||
|
public controls remain locked.
|
||||||
|
|
||||||
|
This server-side bypass is the authoritative rule. The React UI also
|
||||||
|
enables admin controls for usability, but clients are not trusted to
|
||||||
|
enforce permissions.
|
||||||
|
*/
|
||||||
|
return isLightControlLocked() && !isAdmin(socket);
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
|
||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +70,7 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +86,7 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -90,7 +105,7 @@ function registerHomeAssistantHooks(deps) {
|
|||||||
if (!hasPermission()) {
|
if (!hasPermission()) {
|
||||||
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
return cb({ error: 'Insufficient permissions to control Home Assistant' });
|
||||||
}
|
}
|
||||||
if (isLightControlLocked()) {
|
if (isBlockedByRoomControlLock()) {
|
||||||
return cb({ error: 'Room controls are locked' });
|
return cb({ error: 'Room controls are locked' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
|
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
|
||||||
const logger = require('../../globals/logger').child('homeAssistantService');
|
const logger = require('../../globals/logger').child('homeAssistantService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { events } = require('./state');
|
const { events } = require('./state');
|
||||||
const { createRuntimeEngine } = require('./runtimeEngine');
|
const { createRuntimeEngine } = require('./runtimeEngine');
|
||||||
const { createTransport } = require('./transport');
|
const { createTransport } = require('./transport');
|
||||||
@@ -10,7 +11,7 @@ const { registerHomeAssistantHooks } = require('./hooks');
|
|||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const haConfig = config.homeAssistant || {};
|
const haConfig = config.homeAssistant || {};
|
||||||
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
const enabled = isFeatureEnabled('homeAssistant');
|
||||||
|
|
||||||
let callHomeAssistantServiceImpl = async () => {
|
let callHomeAssistantServiceImpl = async () => {
|
||||||
throw new Error('Home Assistant not connected');
|
throw new Error('Home Assistant not connected');
|
||||||
@@ -35,9 +36,23 @@ callHomeAssistantServiceImpl = transport.callHomeAssistantService;
|
|||||||
|
|
||||||
runtimeEngine.loadEntityConfig();
|
runtimeEngine.loadEntityConfig();
|
||||||
runtimeEngine.loadTriggerConfig();
|
runtimeEngine.loadTriggerConfig();
|
||||||
transport.connect();
|
|
||||||
|
|
||||||
registerHomeAssistantHooks({
|
if (enabled) {
|
||||||
|
/*
|
||||||
|
Loading the module should be harmless on rover-only installs. Only connect
|
||||||
|
to Home Assistant when the central feature gate says the integration exists,
|
||||||
|
so placeholder URLs/tokens in example config cannot start network traffic.
|
||||||
|
*/
|
||||||
|
transport.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
/*
|
||||||
|
Socket routes are part of the visible Home Assistant feature. Register them
|
||||||
|
only when enabled so disabled installs do not expose hidden controls that
|
||||||
|
the UI has intentionally removed.
|
||||||
|
*/
|
||||||
|
registerHomeAssistantHooks({
|
||||||
logger,
|
logger,
|
||||||
haConfig,
|
haConfig,
|
||||||
isLightControlLocked: runtimeEngine.isLightControlLocked,
|
isLightControlLocked: runtimeEngine.isLightControlLocked,
|
||||||
@@ -46,7 +61,8 @@ registerHomeAssistantHooks({
|
|||||||
setEntityState: runtimeEngine.setEntityState,
|
setEntityState: runtimeEngine.setEntityState,
|
||||||
setLightColor: runtimeEngine.setLightColor,
|
setLightColor: runtimeEngine.setLightColor,
|
||||||
setLightWhite: runtimeEngine.setLightWhite,
|
setLightWhite: runtimeEngine.setLightWhite,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getState: runtimeEngine.getState,
|
getState: runtimeEngine.getState,
|
||||||
|
|||||||
@@ -426,14 +426,26 @@ function createRuntimeEngine(deps) {
|
|||||||
async function setLightsLockedOn(nextValue, options = {}) {
|
async function setLightsLockedOn(nextValue, options = {}) {
|
||||||
const next = Boolean(nextValue);
|
const next = Boolean(nextValue);
|
||||||
const targetState = options?.targetState === 'off' ? 'off' : 'on';
|
const targetState = options?.targetState === 'off' ? 'off' : 'on';
|
||||||
const forceApply = Boolean(options.forceApply);
|
|
||||||
const nextLockState = next ? targetState : null;
|
const nextLockState = next ? targetState : null;
|
||||||
const changed = runtime.lightsLockState !== nextLockState;
|
const changed = runtime.lightsLockState !== nextLockState;
|
||||||
runtime.lightsLockState = nextLockState;
|
runtime.lightsLockState = nextLockState;
|
||||||
|
|
||||||
if (runtime.lightsLockState != null) {
|
if (runtime.lightsLockState != null) {
|
||||||
if ((changed || forceApply) && enabled) {
|
if (changed && enabled) {
|
||||||
const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
|
const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
|
||||||
|
/*
|
||||||
|
A room-light lock is a policy boundary, not an ongoing reconciliation
|
||||||
|
loop. Entering locked-on or locked-off sets every configured room
|
||||||
|
control to the preferred state once so the room starts from the
|
||||||
|
requested condition. After that first transition, the server leaves
|
||||||
|
Home Assistant alone so out-of-band controls such as wall switches,
|
||||||
|
Home Assistant dashboards, or vendor apps can still adjust individual
|
||||||
|
lights without being periodically overwritten.
|
||||||
|
|
||||||
|
Older callers may still pass forceApply from the previous behavior.
|
||||||
|
It is intentionally ignored here because repeated lock requests must
|
||||||
|
not become repeated light commands.
|
||||||
|
*/
|
||||||
if (runtime.lightsLockState === 'on') {
|
if (runtime.lightsLockState === 'on') {
|
||||||
// The lock-on path is intentionally stronger than a normal bulk
|
// The lock-on path is intentionally stronger than a normal bulk
|
||||||
// turn_on. It makes actual light entities white while still turning
|
// turn_on. It makes actual light entities white while still turning
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ async function handleTrigger(event = {}) {
|
|||||||
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
||||||
const lockedOn = await toggleLightsLockedOn({
|
const lockedOn = await toggleLightsLockedOn({
|
||||||
source: 'ha-button:lightsLockToggle',
|
source: 'ha-button:lightsLockToggle',
|
||||||
forceApply: true,
|
|
||||||
});
|
});
|
||||||
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
||||||
sendTtsToNonPrivateRovers(message);
|
sendTtsToNonPrivateRovers(message);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { issueCommand } = require('../commandService');
|
|||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
const {
|
const {
|
||||||
HEADLIGHT_DISABLE_ACTION,
|
HEADLIGHT_DISABLE_ACTION,
|
||||||
LASER_DISABLE_ACTION,
|
LASER_DISABLE_ACTION,
|
||||||
@@ -103,6 +104,17 @@ async function disableAllRoverLasers() {
|
|||||||
return { action: 'disableRoverLasers', attempted, failed };
|
return { action: 'disableRoverLasers', attempted, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function disablePtzEmitters() {
|
||||||
|
/*
|
||||||
|
The PTZ camera has its own light APIs and ownership rules, so the idle
|
||||||
|
service delegates the actual Reolink calls to ptzCameraService instead of
|
||||||
|
pretending they are rover commands. This keeps idleService responsible only
|
||||||
|
for "idle fired; run cleanup actions" and keeps camera-specific payload
|
||||||
|
details beside the rest of the PTZ integration.
|
||||||
|
*/
|
||||||
|
return ptzCameraService.disableEmittersForIdle();
|
||||||
|
}
|
||||||
|
|
||||||
async function sendNeatoHome() {
|
async function sendNeatoHome() {
|
||||||
try {
|
try {
|
||||||
await neatoService.sendHome();
|
await neatoService.sendHome();
|
||||||
@@ -126,6 +138,7 @@ const idleActions = [
|
|||||||
// dockAllRovers,
|
// dockAllRovers,
|
||||||
disableAllRoverHeadlights,
|
disableAllRoverHeadlights,
|
||||||
disableAllRoverLasers,
|
disableAllRoverLasers,
|
||||||
|
disablePtzEmitters,
|
||||||
sendNeatoHome,
|
sendNeatoHome,
|
||||||
raiseLift,
|
raiseLift,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,33 +1,63 @@
|
|||||||
// Idle Service
|
// Idle Service
|
||||||
// Purpose: Triggers a modular idle action pipeline after a sustained no-driver period.
|
// Purpose: Triggers a modular idle action pipeline after a sustained no-operator-online period.
|
||||||
// Scope: Observes driver activity events and coordinates timer-based idle automation execution.
|
// Scope: Observes user/admin socket presence and coordinates timer-based idle automation execution.
|
||||||
const logger = require('../../globals/logger').child('idleService');
|
const logger = require('../../globals/logger').child('idleService');
|
||||||
const { getActiveDrivers, turnEvents } = require('../turnService');
|
const io = require('../../globals/io');
|
||||||
const roverManager = require('../roverManager');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
const { getRecentDriveActivity } = require('../commandService');
|
|
||||||
const { IDLE_TIMEOUT_MS } = require('./constants');
|
const { IDLE_TIMEOUT_MS } = require('./constants');
|
||||||
const { runtime } = require('./state');
|
const { runtime } = require('./state');
|
||||||
const { runIdleActions } = require('./actions');
|
const { runIdleActions } = require('./actions');
|
||||||
|
|
||||||
function getActivitySnapshot() {
|
function getActivitySnapshot() {
|
||||||
const active = getActiveDrivers();
|
let onlineUsers = 0;
|
||||||
const turnCount = active && typeof active === 'object' ? Object.keys(active).length : 0;
|
let onlineAdmins = 0;
|
||||||
const activeByTurn = turnCount;
|
let onlineSpectators = 0;
|
||||||
|
let onlineIgnored = 0;
|
||||||
|
|
||||||
let liveCount = 0;
|
io.sockets.sockets.forEach((socket) => {
|
||||||
roverManager.rovers.forEach((record) => {
|
const role = getRole(socket);
|
||||||
if (record?.drivers?.size > 0) liveCount += 1;
|
|
||||||
|
/*
|
||||||
|
Idle automation is about whether a real operator is present, not whether
|
||||||
|
a browser tab is merely watching. Spectators can leave the room lights,
|
||||||
|
PTZ emitters, and rovers in their automated idle state because they are
|
||||||
|
intentionally read-only and cannot be the person still using the setup.
|
||||||
|
*/
|
||||||
|
if (role === 'spectator') {
|
||||||
|
onlineSpectators += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Lockdown admins are counted with regular admins because both represent a
|
||||||
|
person with operator-level access who may be supervising the room without
|
||||||
|
actively driving a rover. Plain users also count even before they request
|
||||||
|
control, which is the behavior this service now needs.
|
||||||
|
*/
|
||||||
|
if (role === 'admin' || role === 'lockdown') {
|
||||||
|
onlineAdmins += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
onlineUsers += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Unknown future roles should not accidentally keep automation disabled.
|
||||||
|
If a new role should count as an operator, it should be added explicitly
|
||||||
|
above so this policy remains easy to audit.
|
||||||
|
*/
|
||||||
|
onlineIgnored += 1;
|
||||||
});
|
});
|
||||||
const activeByRoverDrivers = liveCount;
|
|
||||||
|
|
||||||
const recentDriveEvents = getRecentDriveActivity(IDLE_TIMEOUT_MS, { excludeAdmins: false });
|
const totalActive = onlineUsers + onlineAdmins;
|
||||||
const activeByRecentDrive = recentDriveEvents.length;
|
|
||||||
|
|
||||||
const totalActive = Math.max(activeByTurn, activeByRoverDrivers, activeByRecentDrive);
|
|
||||||
return {
|
return {
|
||||||
activeByTurn,
|
onlineUsers,
|
||||||
activeByRoverDrivers,
|
onlineAdmins,
|
||||||
activeByRecentDrive,
|
onlineSpectators,
|
||||||
|
onlineIgnored,
|
||||||
totalActive,
|
totalActive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -43,6 +73,12 @@ function clearIdleTimer() {
|
|||||||
|
|
||||||
function scheduleIdleTimer() {
|
function scheduleIdleTimer() {
|
||||||
if (runtime.timer) return;
|
if (runtime.timer) return;
|
||||||
|
if (runtime.idleActionsCompleted) {
|
||||||
|
logger.info('Idle timer not scheduled; idle actions already completed for this no-operator window', {
|
||||||
|
lastTriggeredAt: runtime.lastTriggeredAt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS;
|
runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS;
|
||||||
logger.info('Idle timer scheduled', {
|
logger.info('Idle timer scheduled', {
|
||||||
timeoutMs: IDLE_TIMEOUT_MS,
|
timeoutMs: IDLE_TIMEOUT_MS,
|
||||||
@@ -53,10 +89,17 @@ function scheduleIdleTimer() {
|
|||||||
runtime.deadlineAt = null;
|
runtime.deadlineAt = null;
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
logger.info('Idle automation skipped; active control detected', activity);
|
logger.info('Idle automation skipped; user or admin online', activity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastTriggeredAt = Date.now();
|
runtime.lastTriggeredAt = Date.now();
|
||||||
|
/*
|
||||||
|
Mark this idle window as handled before running the action pipeline. The
|
||||||
|
pipeline can take time and can call into services that emit their own
|
||||||
|
state changes; setting the guard first prevents any nested refresh from
|
||||||
|
scheduling a second timer for the same continuous no-operator period.
|
||||||
|
*/
|
||||||
|
runtime.idleActionsCompleted = true;
|
||||||
const results = await runIdleActions();
|
const results = await runIdleActions();
|
||||||
logger.info('Idle automation executed', {
|
logger.info('Idle automation executed', {
|
||||||
idleMs: IDLE_TIMEOUT_MS,
|
idleMs: IDLE_TIMEOUT_MS,
|
||||||
@@ -72,13 +115,32 @@ function refreshIdleState() {
|
|||||||
logger.info('Idle state refresh', activity);
|
logger.info('Idle state refresh', activity);
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
|
if (runtime.idleActionsCompleted) {
|
||||||
|
logger.info('Idle action one-shot reset; operator is online again', activity);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
A user/admin coming online starts a new activity window. When the room
|
||||||
|
later becomes idle again, the cleanup pipeline should be allowed to run
|
||||||
|
once for that new idle period.
|
||||||
|
*/
|
||||||
|
runtime.idleActionsCompleted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleIdleTimer();
|
scheduleIdleTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
turnEvents.on('activeDriver', refreshIdleState);
|
io.on('connection', (socket) => {
|
||||||
turnEvents.on('queue', refreshIdleState);
|
/*
|
||||||
|
A user/admin can be online without ever touching rover controls, so socket
|
||||||
|
presence has to be a first-class idle signal. The disconnect hook is just as
|
||||||
|
important: it is what starts the idle timeout after the last non-spectator
|
||||||
|
leaves, even if no driving event happens around that departure.
|
||||||
|
*/
|
||||||
|
refreshIdleState();
|
||||||
|
socket.on('disconnect', refreshIdleState);
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', refreshIdleState);
|
||||||
|
|
||||||
refreshIdleState();
|
refreshIdleState();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const runtime = {
|
|||||||
timer: null,
|
timer: null,
|
||||||
deadlineAt: null,
|
deadlineAt: null,
|
||||||
lastTriggeredAt: null,
|
lastTriggeredAt: null,
|
||||||
|
idleActionsCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
// Inter Instance Service
|
||||||
|
// Purpose: Publishes this server's public instance profile and polls public profiles from peer servers.
|
||||||
|
// Scope: Owns only the inter-instance directory/API contract; local control, auth, and rover state stay in their existing services.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { app } = require('../../globals/http');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('interInstanceService');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { getFeatureFlags, getConfiguredSocials } = require('../../helpers/features');
|
||||||
|
const { getMode, MODES } = require('../modeManager');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { getTurnQueues } = require('../turnService');
|
||||||
|
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
||||||
|
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
||||||
|
const { getRole } = require('../roleService');
|
||||||
|
const { getNickname } = require('../nicknameService');
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 30000;
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
const INFO_PATH = '/api/inter-instance/info';
|
||||||
|
const INSTANCE_ID = uuidv4();
|
||||||
|
const config = loadConfig();
|
||||||
|
const interInstanceConfig = config.interInstance || {};
|
||||||
|
const profileConfig = interInstanceConfig.profile || {};
|
||||||
|
const interInstanceEvents = new EventEmitter();
|
||||||
|
const remoteInstances = new Map();
|
||||||
|
|
||||||
|
let polling = false;
|
||||||
|
function asTrimmedString(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value) {
|
||||||
|
const raw = asTrimmedString(value);
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.search = '';
|
||||||
|
return parsed.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return Boolean(interInstanceConfig.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTimeoutMs() {
|
||||||
|
const value = Number(interInstanceConfig.requestTimeoutMs);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollIntervalMs() {
|
||||||
|
const value = Number(interInstanceConfig.pollIntervalMs);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_POLL_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownPublicUrl() {
|
||||||
|
return normalizeBaseUrl(profileConfig.publicUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownInstanceId() {
|
||||||
|
/*
|
||||||
|
This id exists only for this Node process. That is enough to detect self
|
||||||
|
aliases during a poll cycle because every public URL that reaches this same
|
||||||
|
running server returns the same generated value.
|
||||||
|
*/
|
||||||
|
return INSTANCE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicUrl(pathname) {
|
||||||
|
const base = ownPublicUrl();
|
||||||
|
if (!base || !pathname) return null;
|
||||||
|
return `${base}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicProfile() {
|
||||||
|
const publicUrl = ownPublicUrl();
|
||||||
|
return {
|
||||||
|
id: ownInstanceId(),
|
||||||
|
name: asTrimmedString(profileConfig.name) || publicUrl || 'Rover server',
|
||||||
|
description: asTrimmedString(profileConfig.description),
|
||||||
|
color: asTrimmedString(profileConfig.color),
|
||||||
|
publicUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLockdownMode() {
|
||||||
|
return getMode() === MODES.LOCKDOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserEntry(socket) {
|
||||||
|
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
||||||
|
return {
|
||||||
|
socketId: socket.id,
|
||||||
|
userId: socket?.data?.userId || null,
|
||||||
|
nickname: getNickname(socket) || null,
|
||||||
|
role: getRole(socket),
|
||||||
|
roverId: primaryRover || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRoverSnapshotLinks(rover) {
|
||||||
|
const id = String(rover?.id || '').trim();
|
||||||
|
if (!id) return rover;
|
||||||
|
const state = getRoverSnapshotState(id);
|
||||||
|
const latestUrl = buildPublicUrl(`/api/inter-instance/rover-snapshots/${encodeURIComponent(id)}/latest`);
|
||||||
|
if (!latestUrl) return rover;
|
||||||
|
return {
|
||||||
|
...rover,
|
||||||
|
snapshots: {
|
||||||
|
latestUrl,
|
||||||
|
updatedAt: state?.ts || null,
|
||||||
|
error: state?.error || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoomCameraInfo(camera) {
|
||||||
|
const state = getRoomCameraState(camera.id);
|
||||||
|
const snapshotUrl = buildPublicUrl(`/api/inter-instance/room-cameras/${encodeURIComponent(camera.id)}/snapshot`);
|
||||||
|
/*
|
||||||
|
Room camera config can point at private LAN URLs. The inter-instance payload
|
||||||
|
advertises this server's public snapshot endpoint instead, so remote clients
|
||||||
|
do not learn or depend on the local camera's internal address.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
id: camera.id,
|
||||||
|
name: camera.name,
|
||||||
|
description: camera.description || null,
|
||||||
|
snapshotUrl,
|
||||||
|
updatedAt: state?.ts || null,
|
||||||
|
error: state?.error || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosedPrivateRover(rover) {
|
||||||
|
return Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicRoster() {
|
||||||
|
return roverManager
|
||||||
|
.getRoster()
|
||||||
|
/*
|
||||||
|
Closed private rovers are intentionally absent from the public
|
||||||
|
inter-instance contract. If a rover is private and closed, other servers
|
||||||
|
should not see its row or receive any derived snapshot URL for it.
|
||||||
|
*/
|
||||||
|
.filter((rover) => !isClosedPrivateRover(rover));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
/*
|
||||||
|
A user's current rover id is also part of the public inter-instance surface.
|
||||||
|
If that rover is not in the public roster, scrub only that association while
|
||||||
|
leaving the rest of the public user entry intact for normal queue display.
|
||||||
|
*/
|
||||||
|
return users.map((user) => {
|
||||||
|
const roverId = user?.roverId ? String(user.roverId) : '';
|
||||||
|
if (!roverId || visible.has(roverId)) return user;
|
||||||
|
return { ...user, roverId: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalInfo() {
|
||||||
|
const mode = getMode();
|
||||||
|
const lockdown = isLockdownMode();
|
||||||
|
const features = getFeatureFlags();
|
||||||
|
const publicRoster = getPublicRoster();
|
||||||
|
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 roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
||||||
|
return {
|
||||||
|
instance: {
|
||||||
|
...publicProfile(),
|
||||||
|
mode,
|
||||||
|
open: mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN,
|
||||||
|
features,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
roster,
|
||||||
|
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
|
||||||
|
users,
|
||||||
|
roomCameras,
|
||||||
|
socials: features.socials ? getConfiguredSocials(config) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJpegState(res, state, missingMessage) {
|
||||||
|
if (!state?.frame) {
|
||||||
|
res.status(404).json({ error: missingMessage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.set('X-Rover-Snapshot-Ts', String(state.ts || ''));
|
||||||
|
res.type('jpeg').send(state.frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get(INFO_PATH, (req, res) => {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
res.status(404).json({ error: 'Inter-instance sharing disabled' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.json(buildLocalInfo());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/inter-instance/rover-snapshots/:roverId/latest', (req, res) => {
|
||||||
|
if (!isEnabled() || isLockdownMode()) {
|
||||||
|
res.status(404).json({ error: 'Snapshot unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roverId = String(req.params.roverId || '');
|
||||||
|
const publicRover = getPublicRoster().find((rover) => String(rover.id) === roverId);
|
||||||
|
if (!publicRover) {
|
||||||
|
/*
|
||||||
|
Do not rely on "not advertising the URL" as the privacy boundary. Public
|
||||||
|
snapshot hosting must also reject direct requests for closed-private or
|
||||||
|
unknown rovers because old URLs, logs, or guesses can outlive roster state.
|
||||||
|
*/
|
||||||
|
res.status(404).json({ error: 'Rover snapshot unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJpegState(res, getRoverSnapshotState(roverId), 'Rover snapshot unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/inter-instance/room-cameras/:cameraId/snapshot', (req, res) => {
|
||||||
|
if (!isEnabled() || isLockdownMode()) {
|
||||||
|
res.status(404).json({ error: 'Snapshot unavailable' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraId = String(req.params.cameraId || '');
|
||||||
|
sendJpegState(res, getRoomCameraState(cameraId), 'Room camera snapshot unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeDirectoryEntry(entry) {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
const url = normalizeBaseUrl(entry);
|
||||||
|
return url ? { url, name: '' } : null;
|
||||||
|
}
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
const url = normalizeBaseUrl(entry.url || entry.baseUrl || entry.publicUrl);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
name: asTrimmedString(entry.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueDirectoryEntries(entries) {
|
||||||
|
const seen = new Set();
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
if (!entry?.url || seen.has(entry.url)) return false;
|
||||||
|
seen.add(entry.url);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), requestTimeoutMs());
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDirectoryEntries() {
|
||||||
|
const urls = Array.isArray(interInstanceConfig.directoryUrls)
|
||||||
|
? interInstanceConfig.directoryUrls.map((url) => asTrimmedString(url)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const lists = await Promise.allSettled(urls.map((url) => fetchJson(url)));
|
||||||
|
const entries = [];
|
||||||
|
lists.forEach((result, idx) => {
|
||||||
|
if (result.status !== 'fulfilled') {
|
||||||
|
logger.warn('Directory fetch failed', { url: urls[idx], error: result.reason?.message || String(result.reason) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(result.value)) {
|
||||||
|
logger.warn('Directory response was not an array', { url: urls[idx] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.value.forEach((entry) => {
|
||||||
|
const normalized = normalizeDirectoryEntry(entry);
|
||||||
|
if (normalized) entries.push(normalized);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const self = ownPublicUrl();
|
||||||
|
return uniqueDirectoryEntries(entries).filter((entry) => entry.url !== self);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRemotePayload(entry, payload) {
|
||||||
|
const instance = payload?.instance && typeof payload.instance === 'object' ? payload.instance : {};
|
||||||
|
/*
|
||||||
|
Remote payloads are intentionally additive. Every read below has a passive
|
||||||
|
fallback so older or partially configured servers still produce a useful
|
||||||
|
listing instead of breaking the whole directory view.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
url: entry.url,
|
||||||
|
online: true,
|
||||||
|
lastSuccessAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
latencyMs: null,
|
||||||
|
instance: {
|
||||||
|
...instance,
|
||||||
|
id: asTrimmedString(instance.id),
|
||||||
|
name: asTrimmedString(instance.name) || entry.name || entry.url,
|
||||||
|
publicUrl: normalizeBaseUrl(instance.publicUrl) || entry.url,
|
||||||
|
description: asTrimmedString(instance.description),
|
||||||
|
color: asTrimmedString(instance.color),
|
||||||
|
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 : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteIdentityKey(remote) {
|
||||||
|
const advertisedId = asTrimmedString(remote?.instance?.id);
|
||||||
|
if (advertisedId) return `id:${advertisedId}`;
|
||||||
|
const advertisedPublicUrl = normalizeBaseUrl(remote?.instance?.publicUrl);
|
||||||
|
if (advertisedPublicUrl) return `url:${advertisedPublicUrl}`;
|
||||||
|
return `url:${normalizeBaseUrl(remote?.url) || remote?.url || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelfRemote(remote) {
|
||||||
|
const ownId = ownInstanceId();
|
||||||
|
const remoteId = asTrimmedString(remote?.instance?.id);
|
||||||
|
if (ownId && remoteId && ownId === remoteId) return true;
|
||||||
|
const self = ownPublicUrl();
|
||||||
|
const remotePublicUrl = normalizeBaseUrl(remote?.instance?.publicUrl);
|
||||||
|
const remoteUrl = normalizeBaseUrl(remote?.url);
|
||||||
|
return Boolean(self && (remotePublicUrl === self || remoteUrl === self));
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferRemoteEntry(current, candidate) {
|
||||||
|
/*
|
||||||
|
When the directory has multiple URLs for one instance, keep the healthier
|
||||||
|
entry. Online data beats offline placeholders, and lower latency is a useful
|
||||||
|
tiebreaker when two aliases both work.
|
||||||
|
*/
|
||||||
|
if (!current) return candidate;
|
||||||
|
if (candidate.online && !current.online) return candidate;
|
||||||
|
if (!candidate.online && current.online) return current;
|
||||||
|
if (candidate.online && current.online) {
|
||||||
|
const currentLatency = Number.isFinite(current.latencyMs) ? current.latencyMs : Infinity;
|
||||||
|
const candidateLatency = Number.isFinite(candidate.latencyMs) ? candidate.latencyMs : Infinity;
|
||||||
|
return candidateLatency < currentLatency ? candidate : current;
|
||||||
|
}
|
||||||
|
const currentName = asTrimmedString(current?.instance?.name);
|
||||||
|
const candidateName = asTrimmedString(candidate?.instance?.name);
|
||||||
|
return !currentName && candidateName ? candidate : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceRemoteInstances(nextEntries) {
|
||||||
|
const deduped = new Map();
|
||||||
|
nextEntries.forEach((entry) => {
|
||||||
|
if (!entry || isSelfRemote(entry)) return;
|
||||||
|
const key = remoteIdentityKey(entry);
|
||||||
|
deduped.set(key, preferRemoteEntry(deduped.get(key), entry));
|
||||||
|
});
|
||||||
|
remoteInstances.clear();
|
||||||
|
Array.from(deduped.values()).forEach((entry) => {
|
||||||
|
remoteInstances.set(remoteIdentityKey(entry), entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOffline(entry, error) {
|
||||||
|
const previous = remoteInstances.get(`url:${entry.url}`) || {};
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
url: entry.url,
|
||||||
|
online: false,
|
||||||
|
lastError: error?.message || String(error || 'Unknown error'),
|
||||||
|
instance: {
|
||||||
|
...(previous.instance || {}),
|
||||||
|
name: previous.instance?.name || entry.name || entry.url,
|
||||||
|
publicUrl: previous.instance?.publicUrl || entry.url,
|
||||||
|
},
|
||||||
|
roster: previous.roster || [],
|
||||||
|
turnQueues: previous.turnQueues || {},
|
||||||
|
users: previous.users || [],
|
||||||
|
roomCameras: previous.roomCameras || [],
|
||||||
|
socials: previous.socials || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollRemoteInstance(entry) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const payload = await fetchJson(`${entry.url}${INFO_PATH}`);
|
||||||
|
const normalized = normalizeRemotePayload(entry, payload);
|
||||||
|
normalized.latencyMs = Date.now() - start;
|
||||||
|
return normalized;
|
||||||
|
} catch (err) {
|
||||||
|
return markOffline(entry, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollNow() {
|
||||||
|
if (!isEnabled() || polling) return;
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
const entries = await fetchDirectoryEntries();
|
||||||
|
const nextEntries = await Promise.all(entries.map((entry) => pollRemoteInstance(entry)));
|
||||||
|
replaceRemoteInstances(nextEntries);
|
||||||
|
interInstanceEvents.emit('change');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Inter-instance poll failed', { error: err.message });
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
pollNow();
|
||||||
|
setInterval(pollNow, pollIntervalMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return {
|
||||||
|
enabled: isEnabled(),
|
||||||
|
profile: publicProfile(),
|
||||||
|
instances: Array.from(remoteInstances.values()).sort((a, b) =>
|
||||||
|
String(a.instance?.name || a.url).localeCompare(String(b.instance?.name || b.url)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
startPolling();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getState,
|
||||||
|
interInstanceEvents,
|
||||||
|
buildLocalInfo,
|
||||||
|
pollNow,
|
||||||
|
};
|
||||||
@@ -5,8 +5,9 @@ const EventEmitter = require('events');
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('liftService');
|
const logger = require('../../globals/logger').child('liftService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { isLockdownAdmin } = require('../roleService');
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -19,6 +20,7 @@ const events = new EventEmitter();
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const haConfig = config.homeAssistant || {};
|
const haConfig = config.homeAssistant || {};
|
||||||
const liftConfig = haConfig.lift || {};
|
const liftConfig = haConfig.lift || {};
|
||||||
|
const featureEnabled = isFeatureEnabled('lift');
|
||||||
|
|
||||||
const upSwitchId = String(liftConfig.upSwitch || '').trim();
|
const upSwitchId = String(liftConfig.upSwitch || '').trim();
|
||||||
const downSwitchId = String(liftConfig.downSwitch || '').trim();
|
const downSwitchId = String(liftConfig.downSwitch || '').trim();
|
||||||
@@ -71,7 +73,7 @@ function getState() {
|
|||||||
const configured = isConfigured();
|
const configured = isConfigured();
|
||||||
const connected = isHomeAssistantConnected();
|
const connected = isHomeAssistantConnected();
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(homeAssistantEnabled && configured),
|
enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
|
||||||
configured,
|
configured,
|
||||||
connected,
|
connected,
|
||||||
entities: {
|
entities: {
|
||||||
@@ -102,6 +104,7 @@ function emitUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertReady() {
|
function assertReady() {
|
||||||
|
if (!featureEnabled) throw new Error('Lift is disabled');
|
||||||
if (!isConfigured()) throw new Error('Lift not configured');
|
if (!isConfigured()) throw new Error('Lift not configured');
|
||||||
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
|
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
|
||||||
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
|
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
|
||||||
@@ -173,17 +176,30 @@ async function moveDown(actor = 'unknown') {
|
|||||||
return requestPosition('down', actor);
|
return requestPosition('down', actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
homeAssistantEvents.on('snapshot', emitUpdate);
|
if (featureEnabled) {
|
||||||
homeAssistantEvents.on('status', emitUpdate);
|
/*
|
||||||
|
Lift state depends on Home Assistant switch snapshots. Subscribe only when
|
||||||
|
the lift exists so disabled installs do not maintain hardware-specific UI
|
||||||
|
sync paths.
|
||||||
|
*/
|
||||||
|
homeAssistantEvents.on('snapshot', emitUpdate);
|
||||||
|
homeAssistantEvents.on('status', emitUpdate);
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
function assertFeatureAccess() {
|
||||||
|
const mode = getMode();
|
||||||
|
// Lift is a public activity feature in open and turns modes. Restricted
|
||||||
|
// access modes mirror the rest of the server: admin mode admits normal
|
||||||
|
// admins, while lockdown admits only the explicitly stronger lockdown
|
||||||
|
// role. Enforcing this in the owning service keeps UI buttons and text
|
||||||
|
// command behavior aligned instead of trusting individual callers.
|
||||||
|
if (mode === MODES.ADMIN && !isAdmin(socket)) throw new Error('Admin mode: admins only');
|
||||||
|
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) throw new Error('Server in lockdown');
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
socket.on('lift:up', async (_, cb = () => {}) => {
|
socket.on('lift:up', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
assertFeatureAccess();
|
||||||
throw new Error('Server in lockdown');
|
|
||||||
}
|
|
||||||
// Lift movement is now a public activity feature. Lockdown still wins
|
|
||||||
// above because that mode is the global safety/admin gate for the room.
|
|
||||||
const resp = await moveUp(socket.id || 'socket');
|
const resp = await moveUp(socket.id || 'socket');
|
||||||
cb({ success: true, ...resp });
|
cb({ success: true, ...resp });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -193,18 +209,17 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('lift:down', async (_, cb = () => {}) => {
|
socket.on('lift:down', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
assertFeatureAccess();
|
||||||
throw new Error('Server in lockdown');
|
|
||||||
}
|
|
||||||
// Public access intentionally mirrors lift:up so both directions share
|
|
||||||
// the same policy and cannot drift into different permission behavior.
|
|
||||||
const resp = await moveDown(socket.id || 'socket');
|
const resp = await moveDown(socket.id || 'socket');
|
||||||
cb({ success: true, ...resp });
|
cb({ success: true, ...resp });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Lift disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// llm Commentary Service snapshot engine
|
// llm Commentary Service snapshot engine
|
||||||
// Purpose: Tracks rover activity/history and builds model snapshot payloads from live rover/chat state.
|
// Purpose: Tracks rover activity/history and builds model snapshot payloads from live rover/chat state.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating sensor aggregation and snapshot assembly logic.
|
// Scope: Keeps runtime behavior unchanged while isolating sensor aggregation and snapshot assembly logic.
|
||||||
|
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
|
||||||
|
|
||||||
function createSnapshotEngine(deps) {
|
function createSnapshotEngine(deps) {
|
||||||
const {
|
const {
|
||||||
io,
|
io,
|
||||||
@@ -368,7 +370,12 @@ function createSnapshotEngine(deps) {
|
|||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
||||||
if (!roverId) return true;
|
if (!roverId) return true;
|
||||||
return roverManager.canReplayRoverId(roverId);
|
/*
|
||||||
|
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
|
const chatRecent = allRecentMessages
|
||||||
.filter((entry) => !entry?.bot)
|
.filter((entry) => !entry?.bot)
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ const EventEmitter = require('events');
|
|||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('neatoService');
|
const logger = require('../../globals/logger').child('neatoService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { isLockdownAdmin } = require('../roleService');
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -20,6 +21,7 @@ const events = new EventEmitter();
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const haConfig = config.homeAssistant || {};
|
const haConfig = config.homeAssistant || {};
|
||||||
const neatoConfig = haConfig.neato || {};
|
const neatoConfig = haConfig.neato || {};
|
||||||
|
const featureEnabled = isFeatureEnabled('neato');
|
||||||
|
|
||||||
function normalizeDeviceName(value) {
|
function normalizeDeviceName(value) {
|
||||||
const raw = String(value || '').trim().toLowerCase();
|
const raw = String(value || '').trim().toLowerCase();
|
||||||
@@ -117,7 +119,7 @@ function buildState() {
|
|||||||
const requiredIds = requiredEntityIds();
|
const requiredIds = requiredEntityIds();
|
||||||
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
|
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
|
||||||
const connected = Boolean(haConnected && entitiesAvailable);
|
const connected = Boolean(haConnected && entitiesAvailable);
|
||||||
const enabled = Boolean(homeAssistantEnabled && configured);
|
const enabled = Boolean(featureEnabled && homeAssistantEnabled && configured);
|
||||||
|
|
||||||
const controls = {
|
const controls = {
|
||||||
start: {
|
start: {
|
||||||
@@ -189,15 +191,25 @@ function emitUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
homeAssistantEvents.on('snapshot', () => {
|
if (featureEnabled) {
|
||||||
|
/*
|
||||||
|
Neato telemetry is derived from Home Assistant entities. Disabled installs
|
||||||
|
should keep the exported API inert instead of tracking HA snapshots for a
|
||||||
|
robot vacuum feature that does not exist on that server.
|
||||||
|
*/
|
||||||
|
homeAssistantEvents.on('snapshot', () => {
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
homeAssistantEvents.on('status', () => {
|
homeAssistantEvents.on('status', () => {
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function assertConfiguredAndConnected() {
|
function assertConfiguredAndConnected() {
|
||||||
|
if (!featureEnabled) {
|
||||||
|
throw new Error('Neato is disabled');
|
||||||
|
}
|
||||||
if (!device) {
|
if (!device) {
|
||||||
throw new Error('Neato not configured');
|
throw new Error('Neato not configured');
|
||||||
}
|
}
|
||||||
@@ -255,18 +267,21 @@ function hasVerifiedSockets() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
if (featureEnabled) {
|
||||||
function assertLockdownAccess() {
|
io.on('connection', (socket) => {
|
||||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
function assertFeatureAccess() {
|
||||||
throw new Error('Server in lockdown');
|
const mode = getMode();
|
||||||
}
|
// Neato shares the same public-activity policy as lift: everyone may use
|
||||||
|
// it in open/turns modes, admin mode requires an admin, and lockdown
|
||||||
|
// requires a lockdown admin. This service-level gate protects every socket
|
||||||
|
// action even if a future client bypasses the current UI presentation.
|
||||||
|
if (mode === MODES.ADMIN && !isAdmin(socket)) throw new Error('Admin mode: admins only');
|
||||||
|
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) throw new Error('Server in lockdown');
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('neato:start', async (_, cb = () => {}) => {
|
socket.on('neato:start', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
assertLockdownAccess();
|
assertFeatureAccess();
|
||||||
// Neato commands are public activity features. The lockdown check above
|
|
||||||
// remains the room-wide safety/admin gate when the server is restricted.
|
|
||||||
await startCleaning();
|
await startCleaning();
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -276,8 +291,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:sendHome', async (_, cb = () => {}) => {
|
socket.on('neato:sendHome', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
assertLockdownAccess();
|
assertFeatureAccess();
|
||||||
// Keep send-home public for consistency with the rest of the Neato card.
|
|
||||||
await sendHome();
|
await sendHome();
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -287,8 +301,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:locate', async (_, cb = () => {}) => {
|
socket.on('neato:locate', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
assertLockdownAccess();
|
assertFeatureAccess();
|
||||||
// Locate is a public activity action; lockdown still blocks it above.
|
|
||||||
await locateRobot();
|
await locateRobot();
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -298,9 +311,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
|
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
assertLockdownAccess();
|
assertFeatureAccess();
|
||||||
// Error clearing is grouped with the public Neato controls so the UI does
|
|
||||||
// not show a button that only some public users can actually run.
|
|
||||||
await clearErrors();
|
await clearErrors();
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -310,16 +321,17 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
|
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
assertLockdownAccess();
|
assertFeatureAccess();
|
||||||
// Power cycle follows the same public policy as the rest of the card;
|
|
||||||
// operational safety remains controlled by lockdown mode.
|
|
||||||
await powerCycle();
|
await powerCycle();
|
||||||
cb({ success: true });
|
cb({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Neato disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ function ensureLoaded() {
|
|||||||
lastSampleAt: null,
|
lastSampleAt: null,
|
||||||
lastIntegratedAt: null,
|
lastIntegratedAt: null,
|
||||||
lastDelta: null,
|
lastDelta: null,
|
||||||
|
wheelSpeedsMmPerSecond: {
|
||||||
|
left: null,
|
||||||
|
right: null,
|
||||||
|
center: null,
|
||||||
|
},
|
||||||
rolloverEvents: 0,
|
rolloverEvents: 0,
|
||||||
ignoredSamples: 0,
|
ignoredSamples: 0,
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
@@ -96,6 +101,11 @@ function ensureState(roverId) {
|
|||||||
lastSampleAt: null,
|
lastSampleAt: null,
|
||||||
lastIntegratedAt: null,
|
lastIntegratedAt: null,
|
||||||
lastDelta: null,
|
lastDelta: null,
|
||||||
|
wheelSpeedsMmPerSecond: {
|
||||||
|
left: null,
|
||||||
|
right: null,
|
||||||
|
center: null,
|
||||||
|
},
|
||||||
rolloverEvents: 0,
|
rolloverEvents: 0,
|
||||||
ignoredSamples: 0,
|
ignoredSamples: 0,
|
||||||
status: 'waiting',
|
status: 'waiting',
|
||||||
@@ -187,6 +197,11 @@ function snapshotState(state) {
|
|||||||
lastSampleAt: state.lastSampleAt,
|
lastSampleAt: state.lastSampleAt,
|
||||||
lastIntegratedAt: state.lastIntegratedAt,
|
lastIntegratedAt: state.lastIntegratedAt,
|
||||||
lastDelta: state.lastDelta,
|
lastDelta: state.lastDelta,
|
||||||
|
wheelSpeedsMmPerSecond: state.wheelSpeedsMmPerSecond || {
|
||||||
|
left: null,
|
||||||
|
right: null,
|
||||||
|
center: null,
|
||||||
|
},
|
||||||
rolloverEvents: state.rolloverEvents,
|
rolloverEvents: state.rolloverEvents,
|
||||||
ignoredSamples: state.ignoredSamples,
|
ignoredSamples: state.ignoredSamples,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
@@ -228,6 +243,16 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
if (!Number.isInteger(left) || !Number.isInteger(right)) {
|
if (!Number.isInteger(left) || !Number.isInteger(right)) {
|
||||||
state.status = 'waiting';
|
state.status = 'waiting';
|
||||||
state.statusReason = 'encoder counts missing';
|
state.statusReason = 'encoder counts missing';
|
||||||
|
/*
|
||||||
|
Speed is derived from consecutive encoder samples. When either encoder is
|
||||||
|
absent, keeping stale speed values would make the socket payload look like
|
||||||
|
a live sensor even though the required source data is missing.
|
||||||
|
*/
|
||||||
|
state.wheelSpeedsMmPerSecond = {
|
||||||
|
left: null,
|
||||||
|
right: null,
|
||||||
|
center: null,
|
||||||
|
};
|
||||||
return snapshotState(state);
|
return snapshotState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +266,16 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
state.lastSampleAt = sampleAt;
|
state.lastSampleAt = sampleAt;
|
||||||
state.status = 'tracking';
|
state.status = 'tracking';
|
||||||
state.statusReason = 'baseline ready';
|
state.statusReason = 'baseline ready';
|
||||||
|
/*
|
||||||
|
The baseline frame has valid encoder positions, but no previous sample to
|
||||||
|
compare against. Reporting zero here is intentional: it gives clients a
|
||||||
|
stable wheel-speed sensor shape immediately without inventing movement.
|
||||||
|
*/
|
||||||
|
state.wheelSpeedsMmPerSecond = {
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
center: 0,
|
||||||
|
};
|
||||||
state.updatedAt = sampleAt;
|
state.updatedAt = sampleAt;
|
||||||
const snapshot = snapshotState(state);
|
const snapshot = snapshotState(state);
|
||||||
emitSnapshot(state, snapshot, { force: true });
|
emitSnapshot(state, snapshot, { force: true });
|
||||||
@@ -258,6 +293,30 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
const reasonableLimit = maxReasonableDeltaMm(elapsedMs);
|
const reasonableLimit = maxReasonableDeltaMm(elapsedMs);
|
||||||
const leftRolled = crossedRollover(state.lastLeftCount, left, leftCounts);
|
const leftRolled = crossedRollover(state.lastLeftCount, left, leftCounts);
|
||||||
const rightRolled = crossedRollover(state.lastRightCount, right, rightCounts);
|
const rightRolled = crossedRollover(state.lastRightCount, right, rightCounts);
|
||||||
|
const elapsedSeconds = elapsedMs > 0 ? elapsedMs / 1000 : null;
|
||||||
|
/*
|
||||||
|
The Roomba Open Interface encoder packets are cumulative wheel counts. The
|
||||||
|
odometer already converts each signed count delta into millimeters using the
|
||||||
|
configured Create wheel diameter/counts-per-revolution constants, so wheel
|
||||||
|
speed is the same per-wheel millimeter delta divided by the wall-clock time
|
||||||
|
between accepted sensor frames.
|
||||||
|
*/
|
||||||
|
const rawWheelSpeedsMmPerSecond = elapsedSeconds
|
||||||
|
? {
|
||||||
|
left: leftMm / elapsedSeconds,
|
||||||
|
right: rightMm / elapsedSeconds,
|
||||||
|
center: centerMm / elapsedSeconds,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
center: 0,
|
||||||
|
};
|
||||||
|
const wheelSpeedsMmPerSecond = {
|
||||||
|
left: Math.round(rawWheelSpeedsMmPerSecond.left),
|
||||||
|
right: Math.round(rawWheelSpeedsMmPerSecond.right),
|
||||||
|
center: Math.round(rawWheelSpeedsMmPerSecond.center),
|
||||||
|
};
|
||||||
|
|
||||||
state.lastLeftCount = left;
|
state.lastLeftCount = left;
|
||||||
state.lastRightCount = right;
|
state.lastRightCount = right;
|
||||||
@@ -272,6 +331,16 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
state.ignoredSamples += 1;
|
state.ignoredSamples += 1;
|
||||||
state.status = 'ignored';
|
state.status = 'ignored';
|
||||||
state.statusReason = `ignored ${Math.round(distanceMm)} mm jump`;
|
state.statusReason = `ignored ${Math.round(distanceMm)} mm jump`;
|
||||||
|
/*
|
||||||
|
Rejected encoder jumps are deliberately not surfaced as speed. They are
|
||||||
|
most often reconnect/corruption edges, and showing their implied velocity
|
||||||
|
would produce a dramatic but false wheel-speed sensor spike in the UI.
|
||||||
|
*/
|
||||||
|
state.wheelSpeedsMmPerSecond = {
|
||||||
|
left: null,
|
||||||
|
right: null,
|
||||||
|
center: null,
|
||||||
|
};
|
||||||
state.lastDelta = {
|
state.lastDelta = {
|
||||||
leftCounts,
|
leftCounts,
|
||||||
rightCounts,
|
rightCounts,
|
||||||
@@ -280,6 +349,9 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
centerMm: Math.round(centerMm),
|
centerMm: Math.round(centerMm),
|
||||||
distanceMm: 0,
|
distanceMm: 0,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
|
leftSpeedMmPerSecond: null,
|
||||||
|
rightSpeedMmPerSecond: null,
|
||||||
|
centerSpeedMmPerSecond: null,
|
||||||
ignored: true,
|
ignored: true,
|
||||||
};
|
};
|
||||||
state.updatedAt = sampleAt;
|
state.updatedAt = sampleAt;
|
||||||
@@ -293,6 +365,7 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
state.lastIntegratedAt = sampleAt;
|
state.lastIntegratedAt = sampleAt;
|
||||||
state.status = 'tracking';
|
state.status = 'tracking';
|
||||||
state.statusReason = distanceMm > 0 ? 'integrated encoder delta' : 'no movement';
|
state.statusReason = distanceMm > 0 ? 'integrated encoder delta' : 'no movement';
|
||||||
|
state.wheelSpeedsMmPerSecond = wheelSpeedsMmPerSecond;
|
||||||
state.lastDelta = {
|
state.lastDelta = {
|
||||||
leftCounts,
|
leftCounts,
|
||||||
rightCounts,
|
rightCounts,
|
||||||
@@ -301,6 +374,9 @@ function processSensorFrame(roverId, sensors = {}) {
|
|||||||
centerMm: Math.round(centerMm),
|
centerMm: Math.round(centerMm),
|
||||||
distanceMm: Math.round(distanceMm),
|
distanceMm: Math.round(distanceMm),
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
|
leftSpeedMmPerSecond: wheelSpeedsMmPerSecond.left,
|
||||||
|
rightSpeedMmPerSecond: wheelSpeedsMmPerSecond.right,
|
||||||
|
centerSpeedMmPerSecond: wheelSpeedsMmPerSecond.center,
|
||||||
ignored: false,
|
ignored: false,
|
||||||
};
|
};
|
||||||
state.updatedAt = sampleAt;
|
state.updatedAt = sampleAt;
|
||||||
|
|||||||
+11
-8
@@ -1,12 +1,15 @@
|
|||||||
// Discord Deter Command
|
// Operator Deter Command
|
||||||
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
||||||
// Scope: Supports list, ban, and unban subcommands.
|
// Scope: Supports list, ban, and unban subcommands.
|
||||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
|
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) {
|
||||||
|
// Moderation usage errors use the same core prefix shown by organized help.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
|
|
||||||
return async function handleDeterCommand(message, tokens) {
|
return async function handleDeterCommand(message, tokens) {
|
||||||
if (!isLockdownAdminUser(message.author?.id)) {
|
if (!message.actor?.isLockdownAdmin) {
|
||||||
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -19,7 +22,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
}
|
}
|
||||||
if (action === 'ban') {
|
if (action === 'ban') {
|
||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: 'Usage: `rs deter ban <cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||||
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
||||||
@@ -29,7 +32,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
// full remaining text is now always the selector, which lets lockdown
|
// full remaining text is now always the selector, which lets lockdown
|
||||||
// admins deter multi-word nicknames without quoting or delimiter rules.
|
// admins deter multi-word nicknames without quoting or delimiter rules.
|
||||||
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
|
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
|
||||||
const deterred = deterUser(stableSelector, { actor: message.author?.id || null });
|
const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
|
||||||
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
@@ -37,17 +40,17 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
}
|
}
|
||||||
if (action === 'unban') {
|
if (action === 'unban') {
|
||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: 'Usage: `rs deter unban <id|cookieUserId|nickname|ip>`', allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter unban <id|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
|
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
|
||||||
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.author?.id || null);
|
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.actor?.id || null);
|
||||||
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return message.reply({ content: 'Unknown deter command. Use `rs deter list`, `rs deter ban <selector>`, or `rs deter unban <selector>`.', allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, or \`${commandPrefix} deter unban <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
// Discord Goal Command
|
// Operator Goal Command
|
||||||
// Purpose: Handles global objective view/update/clear operations.
|
// Purpose: Handles global objective view/update/clear operations.
|
||||||
// Scope: Allows read by all and write by admins.
|
// Scope: Allows read by all and write by admins.
|
||||||
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, isAdminUser, sanitizeMentions }) {
|
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, sanitizeMentions }) {
|
||||||
return async function handleGoalCommand(message, tokens) {
|
return async function handleGoalCommand(message, tokens) {
|
||||||
const query = tokens.join(' ').trim();
|
const query = tokens.join(' ').trim();
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
@@ -10,16 +10,16 @@ function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobal
|
|||||||
await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAdminUser(message.author.id)) {
|
if (!message.actor?.isAdmin) {
|
||||||
await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (lower === 'clear') {
|
if (lower === 'clear') {
|
||||||
clearGlobalObjective({ by: message.author?.id || null });
|
clearGlobalObjective({ by: message.actor?.id || null });
|
||||||
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} else {
|
} else {
|
||||||
setGlobalObjective(query, { by: message.author?.id || null });
|
setGlobalObjective(query, { by: message.actor?.id || null });
|
||||||
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Operator Kick Command
|
||||||
|
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
|
||||||
|
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
|
||||||
|
const Fuse = require('fuse.js');
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
|
const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value) {
|
||||||
|
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSelectorAndReason(rawText) {
|
||||||
|
const text = normalizeText(rawText);
|
||||||
|
if (!text) return { selector: '', reason: '' };
|
||||||
|
const pipeIndex = text.indexOf('|');
|
||||||
|
if (pipeIndex >= 0) {
|
||||||
|
/*
|
||||||
|
A pipe delimiter is the escape hatch for multi-word nicknames. Without a
|
||||||
|
delimiter the command intentionally treats the first token as the selector
|
||||||
|
so quick admin commands stay short, for example:
|
||||||
|
`<configured-prefix> kick bob being reckless`.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
selector: normalizeText(text.slice(0, pipeIndex)),
|
||||||
|
reason: normalizeText(text.slice(pipeIndex + 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
return {
|
||||||
|
selector: normalizeText(parts.shift()),
|
||||||
|
reason: normalizeText(parts.join(' ')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKickCandidates({ io, roverManager, assignmentService, getNickname }) {
|
||||||
|
return Array.from(io.sockets.sockets.values())
|
||||||
|
.map((socket) => {
|
||||||
|
const socketId = normalizeText(socket?.id);
|
||||||
|
const assignedRoverId = assignmentService?.getAssignedRover?.(socketId) || null;
|
||||||
|
const primaryRoverId = roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
|
const roverId = assignedRoverId || primaryRoverId || null;
|
||||||
|
if (!socketId || !roverId) return null;
|
||||||
|
const nickname = normalizeText(getNickname(socket));
|
||||||
|
const username = normalizeText(socket?.data?.user?.username);
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
socketId,
|
||||||
|
roverId,
|
||||||
|
nickname,
|
||||||
|
username,
|
||||||
|
label: nickname || username || socketId.slice(0, 6),
|
||||||
|
searchSocketId: normalizeSearchText(socketId),
|
||||||
|
searchShortSocketId: normalizeSearchText(socketId.slice(0, 6)),
|
||||||
|
searchNickname: normalizeSearchText(nickname),
|
||||||
|
searchUsername: normalizeSearchText(username),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKickTarget(selector, candidates, commandPrefix = 'rs') {
|
||||||
|
const query = normalizeSearchText(selector);
|
||||||
|
if (!query) return { error: `Specify a user to kick. Example: \`${commandPrefix} kick nickname reason\`` };
|
||||||
|
const exact = candidates.filter((entry) => (
|
||||||
|
entry.searchSocketId === query ||
|
||||||
|
entry.searchShortSocketId === query ||
|
||||||
|
entry.searchNickname === query ||
|
||||||
|
entry.searchUsername === query
|
||||||
|
));
|
||||||
|
if (exact.length === 1) return { target: exact[0] };
|
||||||
|
if (exact.length > 1) {
|
||||||
|
return { error: `User matched multiple drivers: ${exact.map((entry) => entry.label).join(', ')}.` };
|
||||||
|
}
|
||||||
|
const fuse = new Fuse(candidates, {
|
||||||
|
includeScore: true,
|
||||||
|
threshold: 0.38,
|
||||||
|
ignoreLocation: true,
|
||||||
|
keys: [
|
||||||
|
{ name: 'nickname', weight: 0.7 },
|
||||||
|
{ name: 'username', weight: 0.2 },
|
||||||
|
{ name: 'socketId', weight: 0.1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const results = fuse.search(selector);
|
||||||
|
if (!results.length) return { error: 'User not found among current rover drivers.' };
|
||||||
|
const first = results[0];
|
||||||
|
const second = results[1];
|
||||||
|
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < 0.08) {
|
||||||
|
return {
|
||||||
|
error: `User matched multiple drivers: ${results.slice(0, 5).map((entry) => entry.item.label).join(', ')}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { target: first.item };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, config }) {
|
||||||
|
// The kick parser itself does not need the prefix, but its validation message
|
||||||
|
// does. Keeping this local avoids passing display-only config through the
|
||||||
|
// lower-level fuzzy target resolver except when an error string is needed.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
|
return async function handleKickCommand(message, rawText) {
|
||||||
|
const { selector, reason } = splitSelectorAndReason(rawText);
|
||||||
|
const assignmentService = require('../../assignmentService');
|
||||||
|
const candidates = buildKickCandidates({
|
||||||
|
io,
|
||||||
|
roverManager,
|
||||||
|
assignmentService,
|
||||||
|
getNickname,
|
||||||
|
});
|
||||||
|
const resolved = resolveKickTarget(selector, candidates, commandPrefix);
|
||||||
|
if (resolved.error) {
|
||||||
|
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = resolved.target;
|
||||||
|
const removalReason = reason || DEFAULT_KICK_REASON;
|
||||||
|
/*
|
||||||
|
The command deliberately calls the notice-aware release helper instead of
|
||||||
|
roverManager.releaseControl. That keeps admin kicks aligned with automated
|
||||||
|
removals and gives the driver a stable explanation in the video panel.
|
||||||
|
*/
|
||||||
|
assignmentService.forceReleaseWithNotice(target.roverId, target.socketId, {
|
||||||
|
title: 'Removed by admin',
|
||||||
|
message: removalReason,
|
||||||
|
reasonCode: 'admin-kick',
|
||||||
|
actor: message.actor?.id || null,
|
||||||
|
});
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createKickCommand,
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Lift Feature Command
|
||||||
|
// Purpose: Exposes lift state and movement through the shared text command route.
|
||||||
|
// Scope: Delegates interlocks, cooldowns, Home Assistant access, and runtime safety to liftService.
|
||||||
|
function describeState(state = {}) {
|
||||||
|
const position = state.position || 'unknown';
|
||||||
|
const connection = state.connected ? 'connected' : 'offline';
|
||||||
|
const activity = state.busy ? `moving ${state.target || ''}`.trim() : 'idle';
|
||||||
|
return `Lift: ${connection}; position ${position}; ${activity}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLiftCommand({ liftService, sanitizeMentions }) {
|
||||||
|
return async function handleLiftCommand(message, tokens = []) {
|
||||||
|
const action = String(tokens.shift() || 'status').toLowerCase();
|
||||||
|
if (action === 'status') return message.reply({ content: describeState(liftService.getState()) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'up') await liftService.moveUp(`command:${message.actor?.id || 'unknown'}`);
|
||||||
|
else if (action === 'down') await liftService.moveDown(`command:${message.actor?.id || 'unknown'}`);
|
||||||
|
else return message.reply({ content: 'Invalid lift command. Use `lift status`, `lift up`, or `lift down`.' });
|
||||||
|
return message.reply({ content: `Lift moving ${action}.` });
|
||||||
|
} catch (err) {
|
||||||
|
return message.reply({ content: sanitizeMentions(`Lift command failed: ${err.message}`) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLiftCommand };
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Operator Lights Command
|
||||||
|
// Purpose: Handles admin room-light lock policy commands from Discord and web chat.
|
||||||
|
// Scope: Delegates all actual Home Assistant policy behavior to homeAssistantService.
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
|
function describeLightPolicy(lightPolicy = {}) {
|
||||||
|
// The HA service exposes both the newer explicit lockState and the older
|
||||||
|
// lockedOn boolean. Prefer lockState because it can distinguish locked-on
|
||||||
|
// from locked-off, but keep lockedOn as a defensive fallback for any caller
|
||||||
|
// that passes an older or partial policy object.
|
||||||
|
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
|
||||||
|
if (lockState === 'on') return 'Room lights are locked on.';
|
||||||
|
if (lockState === 'off') return 'Room lights are locked off.';
|
||||||
|
return 'Room lights are unlocked.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLightsCommand({ homeAssistantService, sanitizeMentions, config }) {
|
||||||
|
// The HA policy behavior is prefix-agnostic; this value is only used so
|
||||||
|
// invalid-command guidance points admins at this bot instance's namespace.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
|
return async function handleLightsCommand(message, tokens = []) {
|
||||||
|
// Defaulting to status makes the bare lights command safe to type while
|
||||||
|
// still exposing explicit mutating forms under the configured prefix. This
|
||||||
|
// matters when several bot instances share a Discord server and each one
|
||||||
|
// needs its own command namespace.
|
||||||
|
const action = String(tokens.shift() || 'status').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!homeAssistantService) {
|
||||||
|
await message.reply({
|
||||||
|
content: 'Room light controls are unavailable.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'status') {
|
||||||
|
await message.reply({
|
||||||
|
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== 'lock' && action !== 'unlock') {
|
||||||
|
await message.reply({
|
||||||
|
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locked = action === 'lock';
|
||||||
|
// The bot command intentionally calls the shared policy setter instead of
|
||||||
|
// issuing direct Home Assistant entity commands. That keeps all secondary
|
||||||
|
// behavior centralized: web UI controls become disabled through the
|
||||||
|
// session lightPolicy update, entering lock-on still sets configured
|
||||||
|
// lights to white where possible once, and commandService sees the same
|
||||||
|
// update event that forces rover lasers off while the room is locked on.
|
||||||
|
await homeAssistantService.setLightsLockedOn(locked, {
|
||||||
|
source: `bot-command:lights:${action}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(locked ? 'Room lights locked on.' : 'Room lights unlocked.'),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLightsCommand };
|
||||||
+11
-5
@@ -1,12 +1,16 @@
|
|||||||
// Discord Lock Command
|
// Operator Lock Command
|
||||||
// Purpose: Handles `rs lock` and `rs unlock` operations for rover availability control.
|
// Purpose: Handles lock and unlock operations for rover availability control.
|
||||||
// Scope: Applies lock state updates for a single rover ID.
|
// Scope: Applies lock state updates for a single rover ID.
|
||||||
const { resolveRoverSelector } = require('./resolvers');
|
const { resolveRoverSelector } = require('./resolvers');
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
function createLockCommand({ lockRover, sanitizeMentions, rovers }) {
|
function createLockCommand({ lockRover, sanitizeMentions, rovers, config }) {
|
||||||
|
// Only the user-facing example depends on the prefix. The actual lock logic
|
||||||
|
// still receives the already-parsed rover selector from the shared router.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
return async function handleLockCommand(message, roverId, locked) {
|
return async function handleLockCommand(message, roverId, locked) {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
await message.reply({ content: 'Specify a rover ID. Example: `rs lock alpha`', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: `Specify a rover ID. Example: \`${commandPrefix} lock alpha\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +22,9 @@ function createLockCommand({ lockRover, sanitizeMentions, rovers }) {
|
|||||||
// Mutate by canonical id after fuzzy resolution. This avoids letting a
|
// Mutate by canonical id after fuzzy resolution. This avoids letting a
|
||||||
// display-name typo create a new path through roverManager, and it also
|
// display-name typo create a new path through roverManager, and it also
|
||||||
// makes the response name match the rover that was actually changed.
|
// makes the response name match the rover that was actually changed.
|
||||||
lockRover(resolved.id, locked, { reason: 'discord' });
|
// Preserve the established Discord reason while allowing other adapters
|
||||||
|
// to identify themselves without pretending their request came from Discord.
|
||||||
|
lockRover(resolved.id, locked, { reason: message.transport === 'discord' ? 'discord' : 'web-chat' });
|
||||||
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
// Discord Mode Command
|
// Operator Mode Command
|
||||||
// Purpose: Handles `rs mode` updates from Discord admins.
|
// Purpose: Handles mode updates from authorized operators through the shared command prefix.
|
||||||
// Scope: Validates mode values and applies mode changes with optional reason text.
|
// Scope: Validates mode values and applies mode changes with optional reason text.
|
||||||
function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) {
|
function createModeCommand({ MODES, setMode, setAdminReason, sanitizeMentions }) {
|
||||||
return async function handleModeCommand(message, tokens = []) {
|
return async function handleModeCommand(message, tokens = []) {
|
||||||
const next = String(tokens.shift() || '').toLowerCase();
|
const next = String(tokens.shift() || '').toLowerCase();
|
||||||
const reasonText = tokens.join(' ').trim();
|
const reasonText = tokens.join(' ').trim();
|
||||||
@@ -10,9 +10,9 @@ function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
|
const role = message.actor?.isLockdownAdmin ? 'lockdown' : 'admin';
|
||||||
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
|
setMode(next, { data: { role, user: { username: `${message.transport}:${message.actor?.label || 'unknown'}` } } });
|
||||||
if (reasonText) setAdminReason(reasonText, { by: message.author?.id || null });
|
if (reasonText) setAdminReason(reasonText, { by: message.actor?.id || null });
|
||||||
await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Neato Feature Command
|
||||||
|
// Purpose: Exposes Neato state and supported actions through the shared text command route.
|
||||||
|
// Scope: Delegates device availability, Home Assistant calls, and operational errors to neatoService.
|
||||||
|
function describeState(state = {}) {
|
||||||
|
const telemetry = state.telemetry || {};
|
||||||
|
const connection = state.connected ? 'connected' : 'offline';
|
||||||
|
return `Neato: ${connection}; state ${telemetry.robotState || 'unknown'}; battery ${telemetry.batteryLevel ?? 'unknown'}%.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNeatoCommand({ neatoService, sanitizeMentions }) {
|
||||||
|
return async function handleNeatoCommand(message, tokens = []) {
|
||||||
|
const action = String(tokens.shift() || 'status').toLowerCase();
|
||||||
|
if (action === 'status') return message.reply({ content: describeState(neatoService.getState()) });
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
start: ['now cleaning', neatoService.startCleaning],
|
||||||
|
home: ['returning home', neatoService.sendHome],
|
||||||
|
locate: ['playing locate sound', neatoService.locateRobot],
|
||||||
|
'clear-errors': ['clearing errors', neatoService.clearErrors],
|
||||||
|
};
|
||||||
|
const selected = actions[action];
|
||||||
|
if (!selected) {
|
||||||
|
return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato locate`, or `neato clear-errors`.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await selected[1]();
|
||||||
|
return message.reply({ content: `Neato is ${selected[0]}.` });
|
||||||
|
} catch (err) {
|
||||||
|
return message.reply({ content: sanitizeMentions(`Neato command failed: ${err.message}`) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createNeatoCommand };
|
||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
// Discord Reason Command
|
// Operator Reason Command
|
||||||
// Purpose: Handles admin-mode reason view/update/clear operations.
|
// Purpose: Handles admin-mode reason view/update/clear operations.
|
||||||
// Scope: Allows read by all and write by admins.
|
// Scope: Allows read by all and write by admins.
|
||||||
function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, isAdminUser, sanitizeMentions }) {
|
function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, sanitizeMentions }) {
|
||||||
return async function handleReasonCommand(message, tokens) {
|
return async function handleReasonCommand(message, tokens) {
|
||||||
const query = tokens.join(' ').trim();
|
const query = tokens.join(' ').trim();
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
@@ -10,16 +10,16 @@ function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason,
|
|||||||
await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAdminUser(message.author.id)) {
|
if (!message.actor?.isAdmin) {
|
||||||
await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (lower === 'clear') {
|
if (lower === 'clear') {
|
||||||
clearAdminReason({ by: message.author?.id || null });
|
clearAdminReason({ by: message.actor?.id || null });
|
||||||
await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} else {
|
} else {
|
||||||
setAdminReason(query, { by: message.author?.id || null });
|
setAdminReason(query, { by: message.actor?.id || null });
|
||||||
await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// Discord Command Resolvers
|
// Operator Command Resolvers
|
||||||
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
|
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
|
||||||
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
|
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
|
||||||
const Fuse = require('fuse.js');
|
const Fuse = require('fuse.js');
|
||||||
+9
-6
@@ -1,11 +1,14 @@
|
|||||||
// Discord Verify Command
|
// Operator Verify Command
|
||||||
// Purpose: Handles verified-user moderation commands for lockdown admins.
|
// Purpose: Handles verified-user moderation commands for lockdown admins.
|
||||||
// Scope: Supports list and remove subcommands.
|
// Scope: Supports list and remove subcommands.
|
||||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
|
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, sanitizeMentions, config }) {
|
||||||
|
// Usage text comes from the same core prefix that both transports parse.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
return async function handleVerifyCommand(message, tokens) {
|
return async function handleVerifyCommand(message, tokens) {
|
||||||
if (!isLockdownAdminUser(message.author?.id)) {
|
if (!message.actor?.isLockdownAdmin) {
|
||||||
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -18,7 +21,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
|||||||
}
|
}
|
||||||
if (action === 'remove') {
|
if (action === 'remove') {
|
||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: 'Usage: `rs verify remove <cookieUserId|nickname>`', allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} verify remove <cookieUserId|nickname>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
const resolved = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
const resolved = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||||
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
@@ -26,13 +29,13 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
|||||||
// The command resolver only turns a human-friendly or fuzzy nickname
|
// The command resolver only turns a human-friendly or fuzzy nickname
|
||||||
// into the stable cookie id so the service does not need Discord/Web
|
// into the stable cookie id so the service does not need Discord/Web
|
||||||
// command concerns baked into its storage API.
|
// command concerns baked into its storage API.
|
||||||
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.author?.id || null);
|
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.actor?.id || null);
|
||||||
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return message.reply({ content: 'Unknown verify command. Use `rs verify list` or `rs verify remove <cookieUserId|nickname>`.', allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: `Unknown verify command. Use \`${commandPrefix} verify list\` or \`${commandPrefix} verify remove <cookieUserId|nickname>\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Operator Command Configuration
|
||||||
|
// Purpose: Owns transport-neutral command names used by site chat and optional integrations.
|
||||||
|
// Scope: Prevents Discord configuration from defining whether core server commands can be parsed.
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
|
||||||
|
function getCommandConfig(config = loadConfig()) {
|
||||||
|
const commandConfig = config.commands || {};
|
||||||
|
const prefix = String(commandConfig.prefix || 'rs').trim() || 'rs';
|
||||||
|
const timeStatusCommand = commandConfig.timeStatusCommand === null
|
||||||
|
? ''
|
||||||
|
: String(commandConfig.timeStatusCommand || 'ts').trim();
|
||||||
|
|
||||||
|
return { prefix, timeStatusCommand };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommandText(text, config = loadConfig()) {
|
||||||
|
const clean = String(text || '').trim();
|
||||||
|
const lower = clean.toLowerCase();
|
||||||
|
const { prefix, timeStatusCommand } = getCommandConfig(config);
|
||||||
|
const normalizedPrefix = prefix.toLowerCase();
|
||||||
|
const normalizedTimeStatus = timeStatusCommand.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedTimeStatus && lower === normalizedTimeStatus) {
|
||||||
|
return { matched: true, kind: 'time-status', body: '', action: 'time-status', tokens: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lower.startsWith(normalizedPrefix)) return { matched: false };
|
||||||
|
const nextCharacter = clean.charAt(prefix.length);
|
||||||
|
if (nextCharacter && !/\s/.test(nextCharacter)) return { matched: false };
|
||||||
|
|
||||||
|
const body = clean.slice(prefix.length).trim();
|
||||||
|
const tokens = body ? body.split(/\s+/) : [];
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
kind: 'prefixed',
|
||||||
|
body,
|
||||||
|
action: String(tokens[0] || '').toLowerCase(),
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCommandConfig,
|
||||||
|
parseCommandText,
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Operator Command Help
|
||||||
|
// Purpose: Generates organized command help from one descriptive command catalogue.
|
||||||
|
// Scope: Keeps shared command discovery consistent while allowing Discord-only extensions to stay transport-specific.
|
||||||
|
const { CATEGORIES, buildCommandRegistry } = require('./registry');
|
||||||
|
|
||||||
|
function renderDetailed(name, entry, isFeatureEnabled) {
|
||||||
|
const details = [`**${name}**`, entry.summary];
|
||||||
|
if (entry.access) details.push(`Permission: ${entry.access}`);
|
||||||
|
if (entry.requiredFeature) details.push(`Required feature: ${entry.requiredFeature}`);
|
||||||
|
if (entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature)) details.push('Availability: unavailable on this server');
|
||||||
|
details.push('Usage:', ...entry.usage.map((usage) => `- \`${usage}\``));
|
||||||
|
return details.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '', includeDiscord = true, isFeatureEnabled = () => true } = {}) {
|
||||||
|
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
|
||||||
|
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
|
||||||
|
const entries = buildCommandRegistry(prefix, timeCommand);
|
||||||
|
const normalizedTopic = String(topic || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (entries[normalizedTopic] && (normalizedTopic !== 'bridge' || includeDiscord)) {
|
||||||
|
return renderDetailed(normalizedTopic, entries[normalizedTopic], isFeatureEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
|
||||||
|
const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
|
||||||
|
? [requestedCategory]
|
||||||
|
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
|
||||||
|
|
||||||
|
const output = ['**Rover Bot Commands**'];
|
||||||
|
for (const categoryName of categoryNames) {
|
||||||
|
if (categoryName === 'discord' && !includeDiscord) continue;
|
||||||
|
const category = CATEGORIES[categoryName];
|
||||||
|
output.push('', `**${category.title}**`);
|
||||||
|
for (const name of category.names) {
|
||||||
|
const entry = entries[name];
|
||||||
|
if (!entry?.usage?.length) continue;
|
||||||
|
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
|
||||||
|
output.push(`\`${entry.usage[0]}\` — ${entry.summary}${availability}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push('', `Use \`${prefix} help <command|category>\` for details.`);
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { formatHelp };
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Operator Command Service
|
||||||
|
// Purpose: Routes transport-neutral operator command requests to registered server handlers.
|
||||||
|
// Scope: Owns shared parsing, authorization, feature gating, help, and execution without importing Discord.js.
|
||||||
|
const { formatHelp } = require('./help');
|
||||||
|
const { createLockCommand } = require('./commands/lock');
|
||||||
|
const { createModeCommand } = require('./commands/mode');
|
||||||
|
const { createReasonCommand } = require('./commands/reason');
|
||||||
|
const { createGoalCommand } = require('./commands/goal');
|
||||||
|
const { createVerifyCommand } = require('./commands/verify');
|
||||||
|
const { createDeterCommand } = require('./commands/deter');
|
||||||
|
const { createLightsCommand } = require('./commands/lights');
|
||||||
|
const { createKickCommand } = require('./commands/kick');
|
||||||
|
const { createLiftCommand } = require('./commands/lift');
|
||||||
|
const { createNeatoCommand } = require('./commands/neato');
|
||||||
|
const { getCommandConfig } = require('./config');
|
||||||
|
const { buildCommandRegistry } = require('./registry');
|
||||||
|
|
||||||
|
function createCommandHandlers(deps) {
|
||||||
|
const {
|
||||||
|
getMode,
|
||||||
|
MODES,
|
||||||
|
} = deps;
|
||||||
|
// The prefix belongs to the always-available command system so every
|
||||||
|
// transport parses the same namespace instead of maintaining local defaults.
|
||||||
|
const { prefix: commandPrefix, timeStatusCommand } = getCommandConfig(deps.config);
|
||||||
|
// The legacy time command is a bare word rather than a prefixed command. It
|
||||||
|
// therefore needs its own configurable value, and `null` intentionally
|
||||||
|
// disables it so multiple bots do not all answer `ts` in the same channel.
|
||||||
|
// Lowercase cached copies avoid re-normalizing every message and keep command
|
||||||
|
// matching case-insensitive without changing the original configured text
|
||||||
|
// that is shown in help output.
|
||||||
|
const normalizedCommandPrefix = commandPrefix.toLowerCase();
|
||||||
|
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
|
||||||
|
const registry = buildCommandRegistry(commandPrefix, timeStatusCommand);
|
||||||
|
|
||||||
|
// Status, time status, replay delivery, and transport extensions may have
|
||||||
|
// different presentation needs. Adapters inject those focused handlers while
|
||||||
|
// the core retains parsing, policy, and command discovery ownership.
|
||||||
|
const transportHandlers = deps.transportHandlers || {};
|
||||||
|
const handleStatusCommand = transportHandlers.status;
|
||||||
|
const handleReplayCommand = deps.createReplayTextCommand
|
||||||
|
? deps.createReplayTextCommand(deps)
|
||||||
|
: transportHandlers.replay;
|
||||||
|
const handleLockCommand = createLockCommand(deps);
|
||||||
|
const handleModeCommand = createModeCommand(deps);
|
||||||
|
const handleReasonCommand = createReasonCommand(deps);
|
||||||
|
const handleGoalCommand = createGoalCommand(deps);
|
||||||
|
const handleVerifyCommand = createVerifyCommand(deps);
|
||||||
|
const handleDeterCommand = createDeterCommand(deps);
|
||||||
|
const handleBridgeCommand = transportHandlers.bridge;
|
||||||
|
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
||||||
|
const handleLightsCommand = createLightsCommand(deps);
|
||||||
|
const handleKickCommand = createKickCommand(deps);
|
||||||
|
const handleLiftCommand = createLiftCommand(deps);
|
||||||
|
const handleNeatoCommand = createNeatoCommand(deps);
|
||||||
|
|
||||||
|
function stripCommandPrefix(content) {
|
||||||
|
const trimmed = String(content || '').trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (!lower.startsWith(normalizedCommandPrefix)) return null;
|
||||||
|
|
||||||
|
const nextCharacter = trimmed.charAt(commandPrefix.length);
|
||||||
|
// Prefixes are matched as whole command tokens so an instance using `rs`
|
||||||
|
// still ignores ordinary words such as `rsvp`. This mirrors the old regex
|
||||||
|
// behavior while letting each Discord bot instance use its own prefix.
|
||||||
|
if (nextCharacter && !/\s/.test(nextCharacter)) return null;
|
||||||
|
|
||||||
|
return trimmed.slice(commandPrefix.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(request) {
|
||||||
|
if (request.actor?.bot) return;
|
||||||
|
const content = (request.content || '').trim();
|
||||||
|
const lower = content.toLowerCase();
|
||||||
|
// Commands are intentionally matched as whole prefixes. The previous
|
||||||
|
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
|
||||||
|
// like commands, which is especially bad now that web chat will run the
|
||||||
|
// same server-side dispatcher before broadcasting user text.
|
||||||
|
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand?.(request);
|
||||||
|
|
||||||
|
const commandBody = stripCommandPrefix(content);
|
||||||
|
if (commandBody === null) return;
|
||||||
|
|
||||||
|
const tokens = commandBody ? commandBody.split(/\s+/) : [];
|
||||||
|
const action = (tokens.shift() || '').toLowerCase();
|
||||||
|
const rest = tokens.join(' ').trim();
|
||||||
|
const isAdmin = Boolean(request.actor?.isAdmin);
|
||||||
|
const isLockdownAdmin = Boolean(request.actor?.isLockdownAdmin);
|
||||||
|
const mode = getMode();
|
||||||
|
const commandDefinition = registry[action];
|
||||||
|
if (commandDefinition?.requiredFeature && !deps.isFeatureEnabled(commandDefinition.requiredFeature)) {
|
||||||
|
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is not configured.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Actions in this set can change operational safety or access policy, so
|
||||||
|
// lockdown mode narrows them from normal admins to lockdown admins. Room
|
||||||
|
// light locking belongs here because it can force the physical room lights
|
||||||
|
// on and disables ordinary Home Assistant room controls for everyone else.
|
||||||
|
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
|
||||||
|
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
||||||
|
|
||||||
|
// Feature commands are public activities while access is open or managed
|
||||||
|
// by turns. In admin mode they follow the same admin-only boundary as rover
|
||||||
|
// access, and lockdown continues to require the stricter lockdown role.
|
||||||
|
// Keeping this policy in the shared dispatcher makes web chat and Discord
|
||||||
|
// behave identically instead of each transport interpreting modes itself.
|
||||||
|
if (isAccessModeCommand && mode === MODES.ADMIN && !isAdmin) {
|
||||||
|
await request.reply({ content: 'Admin mode: only admins can run feature commands.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAccessModeCommand && !isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
||||||
|
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
|
||||||
|
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case '':
|
||||||
|
case 'status':
|
||||||
|
return handleStatusCommand?.(request, rest);
|
||||||
|
case 'help':
|
||||||
|
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, topic: rest, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
||||||
|
case 'replay':
|
||||||
|
return handleReplayCommand?.(request, tokens.join(' '));
|
||||||
|
case 'bridge':
|
||||||
|
if (!handleBridgeCommand) return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: false, isFeatureEnabled: deps.isFeatureEnabled }));
|
||||||
|
return handleBridgeCommand(request, tokens);
|
||||||
|
case 'lights':
|
||||||
|
return handleLightsCommand(request, tokens);
|
||||||
|
case 'kick':
|
||||||
|
return handleKickCommand(request, rest);
|
||||||
|
case 'lift':
|
||||||
|
return handleLiftCommand(request, tokens);
|
||||||
|
case 'neato':
|
||||||
|
return handleNeatoCommand(request, tokens);
|
||||||
|
case 'lock':
|
||||||
|
return handleLockCommand(request, rest, true);
|
||||||
|
case 'unlock':
|
||||||
|
return handleLockCommand(request, rest, false);
|
||||||
|
case 'mode':
|
||||||
|
return handleModeCommand(request, tokens);
|
||||||
|
case 'goal':
|
||||||
|
return handleGoalCommand(request, tokens);
|
||||||
|
case 'reason':
|
||||||
|
return handleReasonCommand(request, tokens);
|
||||||
|
case 'verify':
|
||||||
|
return handleVerifyCommand(request, tokens);
|
||||||
|
case 'deter':
|
||||||
|
return handleDeterCommand(request, tokens);
|
||||||
|
default:
|
||||||
|
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleCommand };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createCommandHandlers };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Operator Command Registry
|
||||||
|
// Purpose: Describes command categories, discovery text, permissions, and feature requirements in one place.
|
||||||
|
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||||
|
const CATEGORIES = {
|
||||||
|
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
||||||
|
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
|
||||||
|
features: { title: 'Features', names: ['lift', 'neato'] },
|
||||||
|
discord: { title: 'Discord', names: ['bridge'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCommandRegistry(prefix, timeCommand) {
|
||||||
|
return {
|
||||||
|
help: { category: 'system', summary: 'Show command help.', usage: [`${prefix} help [command|category]`] },
|
||||||
|
status: { category: 'system', summary: 'Show rover status; rover names can be fuzzy.', usage: [`${prefix} status [rover]`] },
|
||||||
|
replay: { category: 'system', summary: 'Create an instant replay from selected sources.', usage: [`${prefix} replay [sources]`] },
|
||||||
|
'time-status': { category: 'system', summary: 'Show the current time status.', usage: timeCommand ? [timeCommand] : [] },
|
||||||
|
lock: { category: 'admin', summary: 'Lock a rover.', usage: [`${prefix} lock <rover>`], access: 'Admin', permission: 'admin' },
|
||||||
|
unlock: { category: 'admin', summary: 'Unlock a rover.', usage: [`${prefix} unlock <rover>`], access: 'Admin', permission: 'admin' },
|
||||||
|
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
|
||||||
|
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
|
||||||
|
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
|
||||||
|
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
|
||||||
|
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
|
||||||
|
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
||||||
|
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
||||||
|
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
|
||||||
|
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
|
||||||
|
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CATEGORIES, buildCommandRegistry };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Web Chat Command Transport
|
||||||
|
// Purpose: Renders status-oriented operator commands as the same plain text web chat expects.
|
||||||
|
// Scope: Avoids importing Discord.js merely to flatten an embed back into text.
|
||||||
|
const { resolveRoverSelector } = require('./commands/resolvers');
|
||||||
|
|
||||||
|
function formatTimeInZone(date, timeZone) {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date);
|
||||||
|
} catch (_err) {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' }) {
|
||||||
|
return {
|
||||||
|
async status(message, roverId) {
|
||||||
|
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
|
||||||
|
if (roverId && resolved?.error) return message.reply(`Rover Status\n\n${resolved.error}`);
|
||||||
|
const records = roverId
|
||||||
|
? [resolved.record]
|
||||||
|
: Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
|
||||||
|
if (!records.length) return message.reply('Rover Battery Status\n\nNo rovers online.');
|
||||||
|
|
||||||
|
// This mirrors the human-readable content of the established Discord
|
||||||
|
// battery embed while remaining a plain transport-neutral chat result.
|
||||||
|
const fields = records.map((record) => {
|
||||||
|
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || {};
|
||||||
|
const battery = record.batteryState || {};
|
||||||
|
const name = record.meta?.name || record.id;
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const chargingLabel = String(sensors?.chargingState?.label || 'unknown');
|
||||||
|
const charging = ['waiting', 'full charging', 'trickle charging'].includes(chargingLabel.toLowerCase()) || [2, 3, 4].includes(sensors?.chargingState?.code);
|
||||||
|
const lockLabel = record.locked ? `locked${record.lockReason ? ` (${record.lockReason})` : ''}` : 'unlocked';
|
||||||
|
const charge = battery.charge != null && battery.capacity != null ? `${battery.charge}/${battery.capacity}mAh` : 'n/a';
|
||||||
|
const percent = battery.percentDisplay != null ? `${battery.percentDisplay}%` : 'n/a';
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
`Dock: ${docked ? 'docked' : 'undocked'}`,
|
||||||
|
`Charging: ${charging ? `charging (${chargingLabel})` : 'not charging'}`,
|
||||||
|
`Battery: ${charge} (${percent})`,
|
||||||
|
`Voltage: ${sensors?.voltageMv == null ? 'n/a' : `${(sensors.voltageMv / 1000).toFixed(2)}V`}`,
|
||||||
|
`Current: ${sensors?.currentMa == null ? 'n/a' : `${sensors.currentMa}mA`}`,
|
||||||
|
`OI: ${String(sensors?.oiMode?.label || 'unknown').toLowerCase()}`,
|
||||||
|
`Lock: ${lockLabel}`,
|
||||||
|
].join('\n');
|
||||||
|
});
|
||||||
|
return message.reply(['Rover Battery Status', ...fields].join('\n\n'));
|
||||||
|
},
|
||||||
|
async timeStatus(message) {
|
||||||
|
const serverTimezone = config.timezone || config.server?.timezone || process.env.TZ || 'America/New_York';
|
||||||
|
const zones = [
|
||||||
|
['UTC', 'UTC'], ['US Pacific', 'America/Los_Angeles'], ['US Mountain', 'America/Denver'],
|
||||||
|
['US Central', 'America/Chicago'], ['US Eastern', 'America/New_York'], ['Europe London', 'Europe/London'],
|
||||||
|
['Europe Berlin', 'Europe/Berlin'], ['Asia Kolkata', 'Asia/Kolkata'], ['Asia Shanghai', 'Asia/Shanghai'],
|
||||||
|
['Asia Tokyo', 'Asia/Tokyo'], ['Australia Sydney', 'Australia/Sydney'], ['New Zealand Auckland', 'Pacific/Auckland'],
|
||||||
|
];
|
||||||
|
const now = new Date();
|
||||||
|
const lines = zones.map(([label, zone]) => `${label} — ${formatTimeInZone(now, zone)}${zone.toLowerCase() === String(serverTimezone).toLowerCase() ? ' **(server local timezone)**' : ''}`);
|
||||||
|
if (!zones.some(([, zone]) => zone.toLowerCase() === String(serverTimezone).toLowerCase())) {
|
||||||
|
lines.push(`Server Local — ${formatTimeInZone(now, serverTimezone)} **(server local timezone)**`);
|
||||||
|
}
|
||||||
|
const siteLink = siteUrl ? `\n\n${siteUrl}` : '';
|
||||||
|
return message.reply(`Time Status\n${lines.join('\n')}${siteLink}\n\nServer local timezone: ${serverTimezone}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWebTransportHandlers };
|
||||||
@@ -15,6 +15,7 @@ const { getState: getNeatoState, neatoEvents } = neatoService;
|
|||||||
const { getState: getLiftState, liftEvents } = liftService;
|
const { getState: getLiftState, liftEvents } = liftService;
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
||||||
|
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
const {
|
const {
|
||||||
PROMPT_PATH,
|
PROMPT_PATH,
|
||||||
@@ -378,7 +379,12 @@ async function runDecision(triggerReason) {
|
|||||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
if (!entry?.roverId) return true;
|
if (!entry?.roverId) return true;
|
||||||
return roverManager.canReplayRoverId(entry.roverId);
|
/*
|
||||||
|
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));
|
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||||
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// PTZ Camera Audio Playback
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
const { resolveDataDir } = require('../../helpers/dataPaths');
|
||||||
|
|
||||||
|
const DEFAULT_CAMERA_NAME = 'trackmix';
|
||||||
|
const DEFAULT_MEDIA_PORT = 9000;
|
||||||
|
const DEFAULT_NEOLINK_BIN = '/usr/local/bin/neolink';
|
||||||
|
const DEFAULT_CHROMEGTTS_WAV_BIN = '/usr/local/bin/chromegtts-wav';
|
||||||
|
const DEFAULT_ESPEAK_BIN = 'espeak';
|
||||||
|
const DEFAULT_FLITE_BIN = 'flite';
|
||||||
|
const DEFAULT_VOLUME = 0.7;
|
||||||
|
const MAX_TEXT_CHARS = 512;
|
||||||
|
const PLAYBACK_TIMEOUT_MS = 45000;
|
||||||
|
|
||||||
|
function clampNumber(value, fallback, min, max) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, number));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(text) {
|
||||||
|
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEngine(engine) {
|
||||||
|
const value = String(engine || '').trim().toLowerCase();
|
||||||
|
if (value === 'espeak' || value === 'e') return 'espeak';
|
||||||
|
if (value === 'flite' || value === 'f') return 'flite';
|
||||||
|
if (['chromegtts', 'googletts', 'gtts', 'google'].includes(value)) return 'chromegtts';
|
||||||
|
return 'chromegtts';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tomlString(value) {
|
||||||
|
/*
|
||||||
|
The generated neolink config is intentionally tiny, so JSON string escaping
|
||||||
|
is enough for TOML basic strings and avoids pulling in a TOML writer just to
|
||||||
|
persist four operator-configured values.
|
||||||
|
*/
|
||||||
|
return JSON.stringify(String(value || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKeyFor(text, ttsOptions) {
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify({ text, ttsOptions }))
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPtzAudioPlayback(deps) {
|
||||||
|
const {
|
||||||
|
logger,
|
||||||
|
cameraConfig,
|
||||||
|
enabled,
|
||||||
|
getSocketLabel,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
const audioConfig = cameraConfig.audio || {};
|
||||||
|
const audioEnabled = audioConfig.enabled === undefined ? Boolean(enabled) : Boolean(audioConfig.enabled);
|
||||||
|
const dataRoot = path.join(resolveDataDir(), 'ptz-camera-audio');
|
||||||
|
const cacheDir = path.join(dataRoot, 'tts-cache');
|
||||||
|
const configPath = path.join(dataRoot, 'neolink-trackmix.toml');
|
||||||
|
const cameraName = String(audioConfig.neolinkCameraName || DEFAULT_CAMERA_NAME).trim() || DEFAULT_CAMERA_NAME;
|
||||||
|
const mediaPort = Number(audioConfig.mediaPort) || DEFAULT_MEDIA_PORT;
|
||||||
|
const neolinkBin = String(audioConfig.neolinkBin || process.env.NEOLINK_BIN || DEFAULT_NEOLINK_BIN).trim();
|
||||||
|
const chromegttsWavBin = String(
|
||||||
|
audioConfig.chromegttsWavBin || process.env.CHROMEGTTS_WAV_BIN || DEFAULT_CHROMEGTTS_WAV_BIN,
|
||||||
|
).trim();
|
||||||
|
const espeakBin = String(audioConfig.espeakBin || process.env.ESPEAK_BIN || DEFAULT_ESPEAK_BIN).trim();
|
||||||
|
const fliteBin = String(audioConfig.fliteBin || process.env.FLITE_BIN || DEFAULT_FLITE_BIN).trim();
|
||||||
|
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
||||||
|
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
||||||
|
|
||||||
|
let playbackProc = null;
|
||||||
|
let playbackSeq = 0;
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return {
|
||||||
|
enabled: audioEnabled,
|
||||||
|
state: playbackProc ? 'playing' : 'idle',
|
||||||
|
/*
|
||||||
|
This state is sent to browser sessions through ptzCamera public state.
|
||||||
|
Keep it operationally useful without leaking server filesystem layout or
|
||||||
|
binary paths that are only meaningful to the Node process.
|
||||||
|
*/
|
||||||
|
cameraName,
|
||||||
|
volume,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureNeolinkConfig() {
|
||||||
|
await fsp.mkdir(dataRoot, { recursive: true });
|
||||||
|
const host = String(cameraConfig.host || '').trim();
|
||||||
|
const username = String(cameraConfig.username || '').trim();
|
||||||
|
const password = String(cameraConfig.password || '');
|
||||||
|
if (!host || !username || !password) {
|
||||||
|
throw new Error('PTZ camera host/username/password required for audio playback');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
'bind = "127.0.0.1"',
|
||||||
|
'',
|
||||||
|
'[[cameras]]',
|
||||||
|
`name = ${tomlString(cameraName)}`,
|
||||||
|
`username = ${tomlString(username)}`,
|
||||||
|
`password = ${tomlString(password)}`,
|
||||||
|
`address = ${tomlString(`${host}:${mediaPort}`)}`,
|
||||||
|
'stream = "subStream"',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
/*
|
||||||
|
Write on every playback instead of trying to detect config drift. The file
|
||||||
|
is small, and this guarantees a camera password/host change in config.yaml
|
||||||
|
is reflected without an extra migration path or manual cleanup.
|
||||||
|
*/
|
||||||
|
await fsp.writeFile(configPath, body, { mode: 0o600 });
|
||||||
|
return configPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnChecked(label, command, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}, options.timeoutMs || PLAYBACK_TIMEOUT_MS);
|
||||||
|
proc.stderr?.on('data', (chunk) => {
|
||||||
|
stderr = `${stderr}${String(chunk || '')}`.slice(-4000);
|
||||||
|
});
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`${label} failed to start: ${err.message}`));
|
||||||
|
});
|
||||||
|
proc.on('exit', (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`${label} exited code=${code} signal=${signal || 'none'} ${stderr.trim()}`.trim()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderEspeak(text, ttsOptions, filePath) {
|
||||||
|
const args = ['-w', filePath];
|
||||||
|
const pitch = clampNumber(ttsOptions.pitch, 50, 0, 99);
|
||||||
|
if (pitch > 0) args.push('-p', String(Math.round(pitch)));
|
||||||
|
args.push(text);
|
||||||
|
await spawnChecked('espeak', espeakBin, args, { timeoutMs: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFlite(text, ttsOptions, filePath) {
|
||||||
|
const args = ['-o', filePath];
|
||||||
|
const voice = String(ttsOptions.voice || fliteDefaultVoice || '').trim();
|
||||||
|
if (voice) args.push('-voice', voice);
|
||||||
|
args.push('-t', text);
|
||||||
|
await spawnChecked('flite', fliteBin, args, { timeoutMs: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderChromeGoogleTts(text, ttsOptions, filePath) {
|
||||||
|
const args = [
|
||||||
|
'--text',
|
||||||
|
text,
|
||||||
|
'--voice',
|
||||||
|
String(ttsOptions.voice || 'tpf'),
|
||||||
|
'--pitch',
|
||||||
|
String(clampNumber(ttsOptions.pitch, 1, 0.5, 2)),
|
||||||
|
'--speed',
|
||||||
|
String(clampNumber(ttsOptions.speed, 1, 0.5, 2)),
|
||||||
|
'--output',
|
||||||
|
filePath,
|
||||||
|
];
|
||||||
|
await spawnChecked('chromegtts-wav', chromegttsWavBin, args, { timeoutMs: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTtsFile(text, rawOptions = {}) {
|
||||||
|
const cleanText = normalizeText(text);
|
||||||
|
if (!cleanText) throw new Error('PTZ TTS text required');
|
||||||
|
const engine = normalizeEngine(rawOptions.engine);
|
||||||
|
const ttsOptions = {
|
||||||
|
engine,
|
||||||
|
voice: typeof rawOptions.voice === 'string' ? rawOptions.voice.trim() : '',
|
||||||
|
pitch: Number.isFinite(rawOptions.pitch) ? rawOptions.pitch : undefined,
|
||||||
|
speed: Number.isFinite(rawOptions.speed) ? rawOptions.speed : undefined,
|
||||||
|
};
|
||||||
|
await fsp.mkdir(cacheDir, { recursive: true });
|
||||||
|
const filePath = path.join(cacheDir, `${cacheKeyFor(cleanText, ttsOptions)}.wav`);
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(filePath);
|
||||||
|
if (stat.isFile() && stat.size > 44) return { filePath, engine, cached: true };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp.wav`;
|
||||||
|
/*
|
||||||
|
Every renderer writes a normal WAV file. Neolink/GStreamer handles the
|
||||||
|
final ADPCM talkback encoding that the Reolink camera expects, so the TTS
|
||||||
|
renderer stays concerned only with faithfully matching the selected rover
|
||||||
|
TTS engine's voice options.
|
||||||
|
*/
|
||||||
|
if (engine === 'espeak') await renderEspeak(cleanText, ttsOptions, tmpPath);
|
||||||
|
else if (engine === 'flite') await renderFlite(cleanText, ttsOptions, tmpPath);
|
||||||
|
else await renderChromeGoogleTts(cleanText, ttsOptions, tmpPath);
|
||||||
|
|
||||||
|
await fsp.rename(tmpPath, filePath);
|
||||||
|
return { filePath, engine, cached: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActivePlayback(reason = 'replace') {
|
||||||
|
if (!playbackProc) return;
|
||||||
|
const proc = playbackProc;
|
||||||
|
playbackProc = null;
|
||||||
|
logger.info('Stopping PTZ TTS playback', { reason, pid: proc.pid || null });
|
||||||
|
try {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (proc.exitCode == null && proc.signalCode == null) {
|
||||||
|
try {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playFile(filePath, context = {}) {
|
||||||
|
if (!audioEnabled) throw new Error('PTZ audio disabled');
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
stopActivePlayback('new-playback');
|
||||||
|
const seq = ++playbackSeq;
|
||||||
|
const args = [
|
||||||
|
'talk',
|
||||||
|
cameraName,
|
||||||
|
'-c',
|
||||||
|
neolinkConfigPath,
|
||||||
|
'--volume',
|
||||||
|
String(volume),
|
||||||
|
'--file-path',
|
||||||
|
filePath,
|
||||||
|
];
|
||||||
|
const proc = spawn(neolinkBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
playbackProc = proc;
|
||||||
|
let stderr = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (playbackProc === proc) stopActivePlayback('timeout');
|
||||||
|
}, PLAYBACK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
proc.stderr?.on('data', (chunk) => {
|
||||||
|
stderr = `${stderr}${String(chunk || '')}`.slice(-4000);
|
||||||
|
});
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (playbackProc === proc) playbackProc = null;
|
||||||
|
logger.warn('PTZ TTS neolink spawn failed', { error: err.message, context });
|
||||||
|
});
|
||||||
|
proc.on('exit', (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (playbackProc === proc) playbackProc = null;
|
||||||
|
if (code === 0 || signal === 'SIGTERM') {
|
||||||
|
logger.info('PTZ TTS playback finished', { code, signal, context });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.warn('PTZ TTS playback failed', {
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
stderr: stderr.trim().slice(-1000),
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('PTZ TTS playback started', {
|
||||||
|
pid: proc.pid || null,
|
||||||
|
filePath,
|
||||||
|
engine: context.engine || null,
|
||||||
|
actor: context.socketId ? getSocketLabel(context.socketId) : null,
|
||||||
|
seq,
|
||||||
|
});
|
||||||
|
return { pid: proc.pid || null, seq };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speakText(text, ttsOptions = {}, context = {}) {
|
||||||
|
const rendered = await ensureTtsFile(text, ttsOptions);
|
||||||
|
await playFile(rendered.filePath, {
|
||||||
|
...context,
|
||||||
|
engine: rendered.engine,
|
||||||
|
cached: rendered.cached,
|
||||||
|
});
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState,
|
||||||
|
speakText,
|
||||||
|
stopActivePlayback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPtzAudioPlayback,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
// Replay Delivery Service
|
||||||
|
// Purpose: Builds each web-requested replay once and chooses Discord or automatic local hosting.
|
||||||
|
// Scope: Keeps replay generation functional even when the optional Discord feature is disabled or unhealthy.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('replayDelivery');
|
||||||
|
const { subscribe } = require('../eventBus');
|
||||||
|
const { buildReplayVideo } = require('../replayEngineV2');
|
||||||
|
const { hostReplay } = require('../replayMediaService');
|
||||||
|
const {
|
||||||
|
createReplayJob,
|
||||||
|
createJobStatusEmitter,
|
||||||
|
buildAcceptedMessage,
|
||||||
|
buildStatusMessage,
|
||||||
|
normalizeUserError,
|
||||||
|
} = require('./workflow');
|
||||||
|
|
||||||
|
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions: (value) => String(value || '') });
|
||||||
|
let preferredDeliveryProvider = null;
|
||||||
|
|
||||||
|
function registerPreferredDeliveryProvider(provider) {
|
||||||
|
preferredDeliveryProvider = provider && typeof provider.deliver === 'function' ? provider : null;
|
||||||
|
return () => {
|
||||||
|
if (preferredDeliveryProvider === provider) preferredDeliveryProvider = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverReplay(payload = {}) {
|
||||||
|
const job = createReplayJob({
|
||||||
|
id: payload.jobId,
|
||||||
|
requester: payload.requester,
|
||||||
|
source: 'web',
|
||||||
|
title: payload.title,
|
||||||
|
sources: payload.sources,
|
||||||
|
includeSidebar: payload.includeSidebar,
|
||||||
|
requestedBy: payload.requestedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||||
|
let providerContext = null;
|
||||||
|
try {
|
||||||
|
let providerError = null;
|
||||||
|
if (preferredDeliveryProvider?.begin) {
|
||||||
|
try {
|
||||||
|
providerContext = await preferredDeliveryProvider.begin(job);
|
||||||
|
} catch (err) {
|
||||||
|
providerError = err;
|
||||||
|
logger.warn('Preferred replay delivery could not start; using hosted media', { jobId: job.id, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||||
|
if (providerContext?.progressMessage?.edit) {
|
||||||
|
await providerContext.progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
|
||||||
|
}
|
||||||
|
const built = await buildReplayVideo({
|
||||||
|
sources: job.sources,
|
||||||
|
title: job.title,
|
||||||
|
requester: job.requester,
|
||||||
|
includeSidebar: job.includeSidebar,
|
||||||
|
});
|
||||||
|
|
||||||
|
let media = null;
|
||||||
|
if (preferredDeliveryProvider && !providerError) {
|
||||||
|
try {
|
||||||
|
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
|
||||||
|
media = await preferredDeliveryProvider.deliver({ job, context: providerContext, ...built });
|
||||||
|
} catch (err) {
|
||||||
|
providerError = err;
|
||||||
|
if (!providerError.progressMessage && providerContext?.progressMessage) providerError.progressMessage = providerContext.progressMessage;
|
||||||
|
logger.warn('Preferred replay delivery failed; using hosted media', { jobId: job.id, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!media) media = await hostReplay({ buffer: built.buffer, job });
|
||||||
|
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
|
||||||
|
if (providerError && preferredDeliveryProvider?.completeFallback) {
|
||||||
|
await preferredDeliveryProvider.completeFallback({ job, context: providerContext, media }).catch((err) => {
|
||||||
|
logger.warn('Unable to announce hosted replay fallback', { jobId: job.id, error: err.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (providerContext?.stopTyping) providerContext.stopTyping();
|
||||||
|
|
||||||
|
// A Discord progress message may already exist when upload fails. Let the
|
||||||
|
// provider attach it to the error so fallback can finish that outward UI
|
||||||
|
// instead of leaving a permanent "uploading" message in the channel.
|
||||||
|
if (providerError?.progressMessage?.edit) {
|
||||||
|
await providerError.progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
|
||||||
|
}
|
||||||
|
return media;
|
||||||
|
} catch (err) {
|
||||||
|
if (providerContext?.stopTyping) providerContext.stopTyping();
|
||||||
|
const message = normalizeUserError(err);
|
||||||
|
jobStatus.emit(job, 'failed', { message });
|
||||||
|
if (providerContext?.progressMessage?.edit) {
|
||||||
|
await providerContext.progressMessage.edit({ content: message, allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe('replay.requested', (event) => {
|
||||||
|
deliverReplay(event?.payload || {}).catch((err) => {
|
||||||
|
logger.warn('Replay delivery failed', { error: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
deliverReplay,
|
||||||
|
registerPreferredDeliveryProvider,
|
||||||
|
};
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
// Discord Replay Workflow
|
// Replay Delivery Workflow
|
||||||
// Purpose: Provides the shared replay job, Discord upload, fuzzy source lookup, and user-facing status helpers.
|
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
|
||||||
// Scope: Keeps Discord-command and web-triggered replay delivery on the same status pipeline.
|
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
|
||||||
const Fuse = require('fuse.js');
|
const Fuse = require('fuse.js');
|
||||||
|
|
||||||
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
||||||
@@ -49,7 +49,7 @@ const replayBuilder = createReplayBuilder({
|
|||||||
ensureDir,
|
ensureDir,
|
||||||
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
||||||
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
||||||
getAudioEntriesForRover: segmentStore.getAudioEntriesForRover,
|
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
||||||
overlapping: segmentStore.overlapping,
|
overlapping: segmentStore.overlapping,
|
||||||
});
|
});
|
||||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
|
|||||||
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
|
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) {
|
||||||
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||||
const tentativeEnd = nowMs - guardMs;
|
const tentativeEnd = nowMs - guardMs;
|
||||||
const sourceEnds = [];
|
const sourceEnds = [];
|
||||||
@@ -75,6 +75,33 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
await execFileAsync(FFMPEG_BIN, ['-y','-hide_banner','-loglevel','error','-f','concat','-safe','0','-i',listPath,'-c','copy',outPath]);
|
await execFileAsync(FFMPEG_BIN, ['-y','-hide_banner','-loglevel','error','-f','concat','-safe','0','-i',listPath,'-c','copy',outPath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pinSegmentFiles(entries, tmpDir, prefix) {
|
||||||
|
/*
|
||||||
|
Replay segment files live in a rolling buffer, so cleanup can unlink one
|
||||||
|
while a slower replay build is still working. Pinning selected files into
|
||||||
|
the per-build temp directory gives ffmpeg stable paths for the whole build.
|
||||||
|
A hard link is preferred because it is cheap and keeps the inode alive even
|
||||||
|
when cleanup removes the original directory entry; copyFile is the fallback
|
||||||
|
for filesystems that do not support linking across the involved paths.
|
||||||
|
*/
|
||||||
|
const pinned = [];
|
||||||
|
for (let i = 0; i < entries.length; i += 1) {
|
||||||
|
const entry = entries[i];
|
||||||
|
const pinnedPath = path.join(tmpDir, `${prefix}-${String(i).padStart(4, '0')}.mp4`);
|
||||||
|
try {
|
||||||
|
await fsp.link(entry.filePath, pinnedPath);
|
||||||
|
} catch (linkErr) {
|
||||||
|
try {
|
||||||
|
await fsp.copyFile(entry.filePath, pinnedPath);
|
||||||
|
} catch (copyErr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pinned.push({ ...entry, filePath: pinnedPath });
|
||||||
|
}
|
||||||
|
return pinned;
|
||||||
|
}
|
||||||
|
|
||||||
async function probeMaxFrameSize(paths) {
|
async function probeMaxFrameSize(paths) {
|
||||||
let maxWidth = 0, maxHeight = 0;
|
let maxWidth = 0, maxHeight = 0;
|
||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
@@ -108,7 +135,11 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
for (let i = 0; i < sources.length; i += 1) {
|
for (let i = 0; i < sources.length; i += 1) {
|
||||||
const source = sources[i];
|
const source = sources[i];
|
||||||
const sourceId = String(source.id);
|
const sourceId = String(source.id);
|
||||||
const videoEntries = overlapping(getVideoEntriesForSource({ type: String(source.type), id: sourceId }), tStart, tEnd);
|
const videoEntries = await pinSegmentFiles(
|
||||||
|
overlapping(getVideoEntriesForSource({ type: String(source.type), id: sourceId }), tStart, tEnd),
|
||||||
|
tmpDir,
|
||||||
|
`video-${i}-seg`,
|
||||||
|
);
|
||||||
if (!videoEntries.length) { missingSources.push({ ...source, reason: 'no video coverage in replay window' }); continue; }
|
if (!videoEntries.length) { missingSources.push({ ...source, reason: 'no video coverage in replay window' }); continue; }
|
||||||
|
|
||||||
const videoConcat = path.join(tmpDir, `video-${i}.mp4`);
|
const videoConcat = path.join(tmpDir, `video-${i}.mp4`);
|
||||||
@@ -122,9 +153,18 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
normalizedVideos.push({ path: videoTrimmed, source });
|
normalizedVideos.push({ path: videoTrimmed, source });
|
||||||
usedSources.push(source);
|
usedSources.push(source);
|
||||||
|
|
||||||
if (source.type === 'rover') {
|
const audioEntries = await pinSegmentFiles(
|
||||||
const audioEntries = overlapping(getAudioEntriesForRover(sourceId), tStart, tEnd);
|
overlapping(getAudioEntriesForSource(source), tStart, tEnd),
|
||||||
|
tmpDir,
|
||||||
|
`audio-${i}-seg`,
|
||||||
|
);
|
||||||
if (audioEntries.length) {
|
if (audioEntries.length) {
|
||||||
|
/*
|
||||||
|
Audio workers are separate from selected video sources, even for PTZ
|
||||||
|
where the live camera path carries inline Opus. Trim the matching
|
||||||
|
source-owned audio window here and let the final graph mix every
|
||||||
|
selected source's audio together.
|
||||||
|
*/
|
||||||
const audioConcat = path.join(tmpDir, `audio-${i}.m4a`);
|
const audioConcat = path.join(tmpDir, `audio-${i}.m4a`);
|
||||||
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
|
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
|
||||||
const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`);
|
const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`);
|
||||||
@@ -135,7 +175,6 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
normalizedAudios.push(audioTrimmed);
|
normalizedAudios.push(audioTrimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources');
|
if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources');
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Handles user-visible replay source catalogs and default source selection rules.
|
// Scope: Handles user-visible replay source catalogs and default source selection rules.
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRoomCameras } = require('../roomCameraService');
|
const { getRoomCameras } = require('../roomCameraService');
|
||||||
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
|
||||||
function getReplaySources(socket = null) {
|
function getReplaySources(socket = null) {
|
||||||
const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster();
|
const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster();
|
||||||
@@ -21,7 +22,10 @@ function getReplaySources(socket = null) {
|
|||||||
label: camera.name || camera.id,
|
label: camera.name || camera.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...roverSources, ...roomSources];
|
const ptzSource = ptzCameraService.getReplaySource();
|
||||||
|
const ptzSources = ptzSource ? [ptzSource] : [];
|
||||||
|
|
||||||
|
return [...roverSources, ...roomSources, ...ptzSources];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSource(entry) {
|
function normalizeSource(entry) {
|
||||||
@@ -67,11 +71,14 @@ function getDefaultWebSources(assignment = {}, socket = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDiscordSources() {
|
function getDefaultDiscordSources() {
|
||||||
return getRoomCameras().map((camera) => ({
|
const sources = getRoomCameras().map((camera) => ({
|
||||||
type: 'room',
|
type: 'room',
|
||||||
id: String(camera.id),
|
id: String(camera.id),
|
||||||
label: camera.name || camera.id,
|
label: camera.name || camera.id,
|
||||||
}));
|
}));
|
||||||
|
const ptzSource = ptzCameraService.getReplaySource();
|
||||||
|
if (ptzSource) sources.push(ptzSource);
|
||||||
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const logger = require('../../globals/logger').child('replayEngineV2');
|
|||||||
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
||||||
const { workers, segmentIndex } = require('./state');
|
const { workers, segmentIndex } = require('./state');
|
||||||
const { sourceKey, sourceDirForKey } = require('./sources');
|
const { sourceKey, sourceDirForKey } = require('./sources');
|
||||||
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
|
||||||
async function ensureDir(dir) {
|
async function ensureDir(dir) {
|
||||||
await fsp.mkdir(dir, { recursive: true });
|
await fsp.mkdir(dir, { recursive: true });
|
||||||
@@ -96,6 +97,24 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
return segmentIndex.get(key) || [];
|
return segmentIndex.get(key) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAudioEntriesForSource(source) {
|
||||||
|
/*
|
||||||
|
Rover audio is published as a separate "<rover>-audio" stream, while PTZ
|
||||||
|
replay audio is split into an internal "ptz-camera-audio" worker from the
|
||||||
|
same live camera stream. Keep this mapping close to the segment index so
|
||||||
|
replayBuilder can ask for "audio that belongs to this selected source"
|
||||||
|
without knowing every worker naming convention.
|
||||||
|
*/
|
||||||
|
const type = String(source?.type || '');
|
||||||
|
const id = String(source?.id || '');
|
||||||
|
if (type === 'rover') return getAudioEntriesForRover(id);
|
||||||
|
if (type === 'ptz') {
|
||||||
|
const key = sourceKey({ sourceType: 'ptz', kind: 'audio', id: `${id}-audio` });
|
||||||
|
return segmentIndex.get(key) || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function overlapping(entries, startMs, endMs) {
|
function overlapping(entries, startMs, endMs) {
|
||||||
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
||||||
}
|
}
|
||||||
@@ -123,6 +142,8 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
for (const camera of getRoomCameras()) {
|
for (const camera of getRoomCameras()) {
|
||||||
replaySources.push({ type: 'room', id: String(camera.id), label: camera.name || camera.id });
|
replaySources.push({ type: 'room', id: String(camera.id), label: camera.name || camera.id });
|
||||||
}
|
}
|
||||||
|
const ptzSource = ptzCameraService.getReplaySource();
|
||||||
|
if (ptzSource) replaySources.push(ptzSource);
|
||||||
|
|
||||||
for (const source of replaySources) {
|
for (const source of replaySources) {
|
||||||
const key = sourceKey({ sourceType: source.type, kind: 'video', id: String(source.id) });
|
const key = sourceKey({ sourceType: source.type, kind: 'video', id: String(source.id) });
|
||||||
@@ -154,6 +175,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
cleanupOldFiles,
|
cleanupOldFiles,
|
||||||
getVideoEntriesForSource,
|
getVideoEntriesForSource,
|
||||||
getAudioEntriesForRover,
|
getAudioEntriesForRover,
|
||||||
|
getAudioEntriesForSource,
|
||||||
overlapping,
|
overlapping,
|
||||||
bootstrapIndexFromDisk,
|
bootstrapIndexFromDisk,
|
||||||
getReplayHealthSnapshot,
|
getReplayHealthSnapshot,
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ const { getMode, MODES } = require('../modeManager');
|
|||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { buildReplayJobId, buildReplayTitle } = require('../replayDeliveryService/workflow');
|
||||||
const { buildReplayJobId, buildReplayTitle } = require('../discordBotService/replayWorkflow');
|
|
||||||
|
|
||||||
const config = loadConfig();
|
|
||||||
const discordConfig = config.discord || {};
|
|
||||||
|
|
||||||
function buildRequesterLabel(socket) {
|
function buildRequesterLabel(socket) {
|
||||||
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
|
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
|
||||||
@@ -34,11 +30,6 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
|||||||
cb({ error: 'Replay disabled in lockdown', state: null });
|
cb({ error: 'Replay disabled in lockdown', state: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const channelId = discordConfig?.channels?.replay || null;
|
|
||||||
if (!channelId) {
|
|
||||||
cb({ error: 'Replay channel not configured', state: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
|
||||||
let sources = requestedSources ? validateSources(requestedSources, socket) : [];
|
let sources = requestedSources ? validateSources(requestedSources, socket) : [];
|
||||||
if (!sources.length) {
|
if (!sources.length) {
|
||||||
@@ -65,7 +56,6 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
|||||||
type: 'replay.requested',
|
type: 'replay.requested',
|
||||||
payload: {
|
payload: {
|
||||||
jobId,
|
jobId,
|
||||||
channelId,
|
|
||||||
requester,
|
requester,
|
||||||
title,
|
title,
|
||||||
includeSidebar,
|
includeSidebar,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user