mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
106
Commits
laptoprover
...
ptz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pio
|
||||
.vscode/
|
||||
config.h
|
||||
|
||||
@@ -1,88 +1,102 @@
|
||||
# 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
|
||||
# audio setup. roverd can keep sending horn audio to "horn", forwarded browser
|
||||
# audio to "forward", and TTS to ALSA's default playback path without caring
|
||||
# which physical sound card is underneath the profile.
|
||||
# This intentionally mirrors pi/asound.conf as closely as a normal PC can:
|
||||
# one fixed hardware card, one dmix playback engine, separate softvol controls
|
||||
# for TTS/horn/forwarded audio, and a raw capture alias for the rover mic.
|
||||
#
|
||||
# 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 {
|
||||
type plug
|
||||
|
||||
# Use the system's first normal ALSA playback device as the physical sink.
|
||||
# This avoids referencing "default" here, because this file replaces
|
||||
# pcm.!default below and using it as a slave would recurse.
|
||||
slave.pcm "sysdefault"
|
||||
}
|
||||
|
||||
pcm.roverd_capture {
|
||||
type plug
|
||||
|
||||
# The media publisher records from "default"; with pcm.!default below that
|
||||
# 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"
|
||||
# Mix multiple playback clients in software with a fixed low-cost format.
|
||||
pcm.dmixer {
|
||||
type dmix
|
||||
ipc_key 1024
|
||||
ipc_perm 0666
|
||||
slave {
|
||||
pcm "hw:0,0"
|
||||
format S16_LE
|
||||
rate 16000
|
||||
channels 1
|
||||
period_time 0
|
||||
period_size 1024
|
||||
buffer_size 4096
|
||||
}
|
||||
}
|
||||
|
||||
# TTS volume control (used by default playback path).
|
||||
pcm.tts_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "TTSMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Horn volume control.
|
||||
pcm.horn_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "HornMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Forwarded audio volume control.
|
||||
pcm.forward_softvol {
|
||||
type softvol
|
||||
slave.pcm "roverd_playback"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
type softvol
|
||||
slave.pcm "dmixer"
|
||||
control {
|
||||
name "ForwardMaster"
|
||||
card 0
|
||||
}
|
||||
min_dB -60.0
|
||||
max_dB 12.0
|
||||
}
|
||||
|
||||
# Per-source playback PCMs.
|
||||
pcm.tts {
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
type plug
|
||||
slave.pcm "tts_softvol"
|
||||
}
|
||||
|
||||
pcm.horn {
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
type plug
|
||||
slave.pcm "horn_softvol"
|
||||
}
|
||||
|
||||
pcm.forward {
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
type plug
|
||||
slave.pcm "forward_softvol"
|
||||
}
|
||||
|
||||
pcm.!default {
|
||||
type asym
|
||||
# Capture alias used by laptop rover config defaults.
|
||||
pcm.rovermic {
|
||||
type plug
|
||||
slave.pcm "hw:0,0"
|
||||
}
|
||||
|
||||
# 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"
|
||||
capture.pcm "roverd_capture"
|
||||
# Defaults: TTS direct playback + raw capture on the dedicated laptop sound card.
|
||||
pcm.!default {
|
||||
type asym
|
||||
playback.pcm "tts"
|
||||
capture.pcm "rovermic"
|
||||
}
|
||||
|
||||
ctl.!default {
|
||||
type hw
|
||||
card 0
|
||||
type hw
|
||||
card 0
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -1,42 +1,425 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
install_debian_laptop_deps() {
|
||||
# This profile is deliberately Debian-only. Using apt directly is simpler
|
||||
# than adding a fake cross-distro layer, and it keeps the installed package
|
||||
# set easy to inspect on the actual rover laptop.
|
||||
# The laptop rover is a dedicated appliance, not a normal desktop/laptop audio
|
||||
# install. Keep this package set intentionally close to the Pi profile so the
|
||||
# same roverd TTS/playback/capture code paths are available on both targets.
|
||||
if command -v ffmpeg >/dev/null 2>&1 \
|
||||
&& command -v arecord >/dev/null 2>&1 \
|
||||
&& command -v aplay >/dev/null 2>&1 \
|
||||
&& command -v amixer >/dev/null 2>&1 \
|
||||
&& command -v v4l2-ctl >/dev/null 2>&1 \
|
||||
&& command -v flite >/dev/null 2>&1 \
|
||||
&& command -v espeak >/dev/null 2>&1; then
|
||||
log "Debian laptop media/audio dependencies already installed; skipping apt install"
|
||||
&& command -v espeak >/dev/null 2>&1 \
|
||||
&& 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
|
||||
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 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() {
|
||||
if [[ ! -f pi/asound.debian-laptop.conf ]]; then
|
||||
log "WARNING: pi/asound.debian-laptop.conf missing; skipping Debian laptop ALSA config install"
|
||||
load_or_create_debian_laptop_alsa_config
|
||||
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
|
||||
fi
|
||||
|
||||
# The laptop profile still uses roverd's existing audio contract: TTS plays
|
||||
# to ALSA's default output, horn plays to the named "horn" device, and
|
||||
# forwarded web audio plays to the named "forward" device. Installing one
|
||||
# profile-specific asound.conf gives those paths independent softvol mixer
|
||||
# controls without changing the TTS runtime code.
|
||||
install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf
|
||||
log "Installed Debian laptop ALSA config to /etc/asound.conf"
|
||||
log "ALSA config updated; restarting audio clients or rebooting is recommended before testing laptop audio"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
log "Downloading Google Chrome TTS assets for Debian laptop profile..."
|
||||
if ! curl -L -o "${tmp_dir}/googletts-26.5.tar.xz" "$dist_url"; then
|
||||
rm -rf "$tmp_dir"
|
||||
log "WARNING: failed to download Google Chrome TTS assets; chromegtts will be unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
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_deps
|
||||
disable_debian_laptop_desktop_audio_stack
|
||||
install_debian_laptop_audio_support
|
||||
}
|
||||
|
||||
+28
-22
@@ -177,17 +177,20 @@ type PrivateConfig struct {
|
||||
}
|
||||
|
||||
type PrivateSafetyConfig struct {
|
||||
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
||||
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
||||
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
||||
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
||||
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
||||
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
||||
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
||||
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
||||
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
||||
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
||||
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
||||
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
||||
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
||||
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
||||
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -303,17 +306,20 @@ func LoadConfig(path string) (*Config, error) {
|
||||
Private: PrivateConfig{
|
||||
Enabled: false,
|
||||
Safety: PrivateSafetyConfig{
|
||||
SpeedLimitEnabled: false,
|
||||
SpeedLimitMaxWheelMMs: 250,
|
||||
HardOvercurrentEnabled: false,
|
||||
OvercurrentStopMs: 300,
|
||||
HardBumpEnabled: false,
|
||||
BumpBackoffSpeed: 250,
|
||||
BumpBackoffMs: 350,
|
||||
CliffEnabled: false,
|
||||
CliffBackoffSpeed: 250,
|
||||
CliffBackoffMs: 500,
|
||||
TriggerCooldownMs: 800,
|
||||
SpeedLimitEnabled: false,
|
||||
SpeedLimitMaxWheelMMs: 250,
|
||||
HardOvercurrentEnabled: false,
|
||||
OvercurrentStopMs: 300,
|
||||
HardBumpEnabled: false,
|
||||
BumpBackoffSpeed: 250,
|
||||
BumpBackoffMs: 350,
|
||||
CliffEnabled: false,
|
||||
CliffBackoffSpeed: 250,
|
||||
CliffBackoffMs: 500,
|
||||
VirtualWallEnabled: true,
|
||||
VirtualWallBackoffSpeed: 250,
|
||||
VirtualWallBackoffMs: 500,
|
||||
TriggerCooldownMs: 800,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -95,4 +95,10 @@ private:
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
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
|
||||
|
||||
@@ -104,4 +104,10 @@ private:
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
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
|
||||
|
||||
@@ -69,4 +69,10 @@ private:
|
||||
cliffEnabled: false
|
||||
cliffBackoffSpeed: 250
|
||||
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
|
||||
|
||||
@@ -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,16 @@
|
||||
# make all bandwidth saving options toggleable in one centralized server config
|
||||
- multitab protection mode
|
||||
- allowed
|
||||
- verified only
|
||||
- not allowed
|
||||
- snapshots
|
||||
- non-turn snapshots
|
||||
- on (you see snapshots when its not your turn)
|
||||
- off (everyone gets full video all the time)
|
||||
- non-local spectator snapshots
|
||||
- on (external spectators are only allowed snapshots)
|
||||
- off (all spectators get full video)
|
||||
- external spectator access (new)
|
||||
- off (no one can access the spectate page externally)
|
||||
- on (everyone can access the spectate page externally)
|
||||
- anything else related to bandwidth savings should also get config
|
||||
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)
|
||||
+77
-31
@@ -7,12 +7,27 @@ admins:
|
||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
|
||||
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:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
|
||||
overseerControl:
|
||||
enabled: false
|
||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||
@@ -27,9 +42,12 @@ overseerControl:
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
|
||||
barcodeGames:
|
||||
enabled: false
|
||||
botName: "Barcode Games"
|
||||
profileImageUrl: "https://example.com/barcode-games.png"
|
||||
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
@@ -49,13 +67,16 @@ audioLevels:
|
||||
forwardGain: 1.0
|
||||
|
||||
homeAssistant:
|
||||
enabled: false
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
neato:
|
||||
enabled: false
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
lift:
|
||||
enabled: false
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# Raise sequence: down off -> wait interlockMs -> up on
|
||||
# Lower sequence: up off -> wait interlockMs -> down on
|
||||
@@ -92,17 +113,36 @@ homeAssistant:
|
||||
stateEquals: "toggle"
|
||||
cooldownMs: 1000
|
||||
action: "lightsLockToggle"
|
||||
|
||||
roomCameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
enabled: false
|
||||
cameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
description: "Wide shot of the staging area."
|
||||
url: "http://192.168.0.50/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
||||
- id: "workshop"
|
||||
name: "Workshop Bench"
|
||||
description: "Shows the workbench and charging docks."
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
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:
|
||||
enabled: false
|
||||
@@ -111,15 +151,29 @@ kinect:
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
buttonBox:
|
||||
enabled: false
|
||||
|
||||
barcodeScanner:
|
||||
enabled: false
|
||||
|
||||
discord:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
# Give each bot instance a unique command prefix when several rover servers
|
||||
# share one Discord server. Commands are matched as whole tokens, so "rs"
|
||||
# handles "rs status" but ignores normal words like "rsvp".
|
||||
commandPrefix: "rs"
|
||||
# Set this to null to disable the bare time-status shortcut. It is separate
|
||||
# from commandPrefix because the legacy command is just "ts", and multiple
|
||||
# bots in the same Discord server should not all answer the same bare word.
|
||||
timeStatusCommand: "ts"
|
||||
channels:
|
||||
general: "123456789012345678"
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
# chat bridge is configured per guild via `<commandPrefix> bridge` commands
|
||||
replay: "123456789012345678"
|
||||
humanAlerts: "123456789012345678"
|
||||
roles:
|
||||
@@ -129,23 +183,15 @@ discord:
|
||||
humanAlertPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
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"
|
||||
enabled: false
|
||||
links:
|
||||
- id: "discord"
|
||||
label: "Discord"
|
||||
url: "https://discord.gg/your-invite"
|
||||
icon: "FaDiscord"
|
||||
color: "#5865F2"
|
||||
- id: "kofi"
|
||||
label: "Ko-fi"
|
||||
url: "https://ko-fi.com/your-handle"
|
||||
icon: "FaCoffee"
|
||||
color: "#29ABE0"
|
||||
|
||||
@@ -58,10 +58,16 @@
|
||||
"entityId": "printer",
|
||||
"label": "Medical thermal printer"
|
||||
},
|
||||
"o008": {
|
||||
"o008": {
|
||||
"type": "object",
|
||||
"entityId": "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/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/ptzCameraService');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraService');
|
||||
require('./src/services/roverSnapshotService');
|
||||
require('./src/services/interInstanceService');
|
||||
require('./src/services/humanAlertButtonService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
MEDIAMTX_VERSION="1.15.3"
|
||||
NEOLINK_VERSION="0.6.2"
|
||||
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"
|
||||
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||
@@ -29,6 +33,79 @@ SERVER_DIR="$SCRIPT_DIR"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
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..."
|
||||
# The Kinect tooling uses a native libfreenect worker/probe rather than a
|
||||
@@ -40,9 +117,21 @@ dnf install -y \
|
||||
npm \
|
||||
curl \
|
||||
tar \
|
||||
unzip \
|
||||
xz \
|
||||
gcc-c++ \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
flite \
|
||||
espeak \
|
||||
python3 \
|
||||
libcxx \
|
||||
libcxxabi \
|
||||
gstreamer1 \
|
||||
gstreamer1-plugins-base \
|
||||
gstreamer1-plugins-good \
|
||||
gstreamer1-plugins-bad-free \
|
||||
gstreamer1-rtsp-server \
|
||||
libfreenect \
|
||||
libfreenect-devel \
|
||||
libusb1-devel >/dev/null
|
||||
@@ -62,6 +151,13 @@ EOF
|
||||
chmod 644 "$KINECT_UDEV_RULE"
|
||||
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..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||
|
||||
@@ -83,12 +179,15 @@ arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||
neolink_pkg="neolink_linux_x86_64_ubuntu.zip"
|
||||
;;
|
||||
aarch64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||
neolink_pkg="neolink_linux_arm64.zip"
|
||||
;;
|
||||
armv7l)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||
neolink_pkg="neolink_linux_armhf.zip"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch" >&2
|
||||
@@ -101,6 +200,26 @@ curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||
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"
|
||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||
|
||||
@@ -16,10 +16,24 @@ esac
|
||||
|
||||
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 \
|
||||
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
||||
-an \
|
||||
-vf fps=1 \
|
||||
-q:v 6 \
|
||||
-vf "$FILTER" \
|
||||
-q:v "$QUALITY" \
|
||||
-update 1 \
|
||||
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"morgan": "^1.10.0",
|
||||
"obscenity": "^0.4.6",
|
||||
"ollama": "^0.6.3",
|
||||
"onvif": "^0.8.1",
|
||||
"reolink-nvr-api": "^0.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"socket.io": "^4.7.5",
|
||||
"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/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>
|
||||
<script type="module" crossorigin src="/assets/index-DRqsCa1W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
|
||||
<script type="module" crossorigin src="/assets/index-D5vPzVhj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BN3kEVFL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
const ptzCameraConfig = config.ptzCamera || {};
|
||||
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,
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureFlags() {
|
||||
return buildFeatureFlags(loadConfig());
|
||||
}
|
||||
|
||||
function isFeatureEnabled(featureName) {
|
||||
return Boolean(getFeatureFlags()[featureName]);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildFeatureFlags,
|
||||
getFeatureFlags,
|
||||
isFeatureEnabled,
|
||||
getRoomCameraEntries,
|
||||
getConfiguredSocials,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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.
|
||||
const STROBE_MS = 30 * 1000;
|
||||
const TICK_MS = 500;
|
||||
const STROBE_MS = 60 * 1000;
|
||||
const TICK_MS = 1500;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
@@ -44,7 +44,7 @@ module.exports = {
|
||||
goal: 400,
|
||||
async run(ctx) {
|
||||
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) {
|
||||
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 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) => {
|
||||
socketRefs.set(socket.id, socket);
|
||||
socket.on('disconnect', () => {
|
||||
@@ -176,6 +207,18 @@ function forceRelease(roverId, 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 = {}) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
@@ -266,6 +309,7 @@ module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
forceReleaseWithNotice,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeGameService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { sendSystemMessage } = require('../chatService');
|
||||
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 config = loadConfig();
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const enabled = isFeatureEnabled('barcodeGames');
|
||||
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
|
||||
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
|
||||
|
||||
@@ -1120,34 +1122,43 @@ function broadcastState() {
|
||||
});
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
socket.join(GAME_SOCKET_ROOM);
|
||||
const state = buildStatePayload(socket);
|
||||
socket.emit('barcodeGame:state', state);
|
||||
cb({ success: true, state });
|
||||
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.join(GAME_SOCKET_ROOM);
|
||||
const state = buildStatePayload(socket);
|
||||
socket.emit('barcodeGame:state', state);
|
||||
cb({ success: true, state });
|
||||
});
|
||||
|
||||
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
cb(setVote(socket, gameId));
|
||||
} catch (err) {
|
||||
logger.warn('Barcode game vote failed', { error: err.message, gameId });
|
||||
cb({ error: err.message || 'barcode game vote failed' });
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
|
||||
subscribe('barcode.scanned', (event) => {
|
||||
try {
|
||||
cb(setVote(socket, gameId));
|
||||
handleScan(event.payload);
|
||||
} catch (err) {
|
||||
logger.warn('Barcode game vote failed', { error: err.message, gameId });
|
||||
cb({ error: err.message || 'barcode game vote failed' });
|
||||
// Scanner input should never be able to take down the server. Game failures
|
||||
// are logged and skipped so the scanner page can keep resolving barcodes.
|
||||
logger.warn('Barcode game scan handling failed', { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
subscribe('barcode.scanned', (event) => {
|
||||
try {
|
||||
handleScan(event.payload);
|
||||
} catch (err) {
|
||||
// Scanner input should never be able to take down the server. Game failures
|
||||
// are logged and skipped so the scanner page can keep resolving barcodes.
|
||||
logger.warn('Barcode game scan handling failed', { error: err.message });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info('Barcode games disabled by config');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildStatePayload,
|
||||
@@ -1155,8 +1166,10 @@ module.exports = {
|
||||
setVote,
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
if (settleActiveGameIfNeeded()) {
|
||||
broadcastState();
|
||||
}
|
||||
}, GAME_TICK_MS).unref?.();
|
||||
if (enabled) {
|
||||
setInterval(() => {
|
||||
if (settleActiveGameIfNeeded()) {
|
||||
broadcastState();
|
||||
}
|
||||
}, GAME_TICK_MS).unref?.();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeScannerService');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
||||
@@ -14,6 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
|
||||
const RECENT_SCAN_LIMIT = 8;
|
||||
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
|
||||
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
|
||||
const enabled = isFeatureEnabled('barcodeScanner');
|
||||
|
||||
let lastKnownGoodRegistry = null;
|
||||
let lastRegistryError = null;
|
||||
@@ -310,39 +312,53 @@ async function applyScan(rawCode) {
|
||||
return { result };
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
|
||||
socket.join(SCANNER_SOCKET_ROOM);
|
||||
socket.emit('barcode:state', buildStatePayload());
|
||||
cb({ success: true, state: buildStatePayload() });
|
||||
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.join(SCANNER_SOCKET_ROOM);
|
||||
socket.emit('barcode:state', buildStatePayload());
|
||||
cb({ success: true, state: buildStatePayload() });
|
||||
});
|
||||
|
||||
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const { result } = await applyScan(code);
|
||||
cb({ success: true, result, state: buildStatePayload() });
|
||||
} catch (err) {
|
||||
// Socket handlers should never let a malformed scan or registry edge case
|
||||
// bubble out to the process. The page gets a normal failed acknowledgement
|
||||
// and the service keeps running for the next scan.
|
||||
logger.warn('Barcode scan failed unexpectedly', err);
|
||||
cb({ error: err.message || 'barcode scan failed' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const { result } = await applyScan(code);
|
||||
cb({ success: true, result, state: buildStatePayload() });
|
||||
} catch (err) {
|
||||
// Socket handlers should never let a malformed scan or registry edge case
|
||||
// bubble out to the process. The page gets a normal failed acknowledgement
|
||||
// and the service keeps running for the next scan.
|
||||
logger.warn('Barcode scan failed unexpectedly', err);
|
||||
cb({ error: err.message || 'barcode scan failed' });
|
||||
}
|
||||
modeEvents.on('change', () => {
|
||||
// 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
|
||||
// new scan.
|
||||
broadcastState();
|
||||
});
|
||||
});
|
||||
|
||||
modeEvents.on('change', () => {
|
||||
// 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
|
||||
// new scan.
|
||||
broadcastState();
|
||||
});
|
||||
|
||||
loadRegistryForScan();
|
||||
loadRegistryForScan();
|
||||
} else {
|
||||
logger.info('Barcode scanner disabled by config');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REGISTRY_PATH,
|
||||
applyScan,
|
||||
applyScan: (...args) => {
|
||||
if (!enabled) throw new Error('Barcode scanner is disabled');
|
||||
return applyScan(...args);
|
||||
},
|
||||
buildStatePayload,
|
||||
getRegistrySnapshot,
|
||||
getRegistrySnapshot: () => {
|
||||
if (!enabled) return { registry: null, error: 'barcode scanner disabled' };
|
||||
return getRegistrySnapshot();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('buttonBoxService');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { getRewardById, listRewards } = require('../../rewards');
|
||||
@@ -28,6 +29,7 @@ const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||
const BUTTON_COUNT = 4;
|
||||
const STORE_VERSION = 1;
|
||||
const enabled = isFeatureEnabled('buttonBox');
|
||||
|
||||
const store = createButtonBoxStore({
|
||||
logger,
|
||||
@@ -62,21 +64,39 @@ const core = createButtonBoxCore({
|
||||
store,
|
||||
});
|
||||
|
||||
registerButtonBoxRoute({
|
||||
app,
|
||||
logger,
|
||||
buttonCount: BUTTON_COUNT,
|
||||
normalizeIp,
|
||||
isLocalNetwork,
|
||||
applyPress: core.applyPress,
|
||||
});
|
||||
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,
|
||||
logger,
|
||||
buttonCount: BUTTON_COUNT,
|
||||
normalizeIp,
|
||||
isLocalNetwork,
|
||||
applyPress: core.applyPress,
|
||||
});
|
||||
|
||||
store.loadState();
|
||||
core.recoverEffects().catch((err) => {
|
||||
logger.warn('Button box effect recovery failed', err.message);
|
||||
});
|
||||
store.loadState();
|
||||
core.recoverEffects().catch((err) => {
|
||||
logger.warn('Button box effect recovery failed', err.message);
|
||||
});
|
||||
} else {
|
||||
logger.info('Button box disabled by config');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getButtonBoxState: store.getStateClone,
|
||||
addButtonBoxCount: core.addCount,
|
||||
getButtonBoxState: () => {
|
||||
/*
|
||||
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 { describeAssignment } = require('../assignmentService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
|
||||
function resolvePtzChatTarget(socketId) {
|
||||
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
||||
}
|
||||
|
||||
function resolveRoverId(socketId) {
|
||||
const ptzTarget = resolvePtzChatTarget(socketId);
|
||||
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
if (primary) return primary;
|
||||
const assignment = describeAssignment(socketId);
|
||||
@@ -17,13 +24,54 @@ function resolveRoverId(socketId) {
|
||||
|
||||
function resolveRoverColor(roverId) {
|
||||
if (!roverId) return null;
|
||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||
return ptzCameraService.getPublicState()?.color || null;
|
||||
}
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function resolveRoverName(roverId) {
|
||||
if (!roverId) return null;
|
||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||
return ptzCameraService.getPublicState()?.name || null;
|
||||
}
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.name || null;
|
||||
}
|
||||
|
||||
function isPtzChatTargetId(roverId) {
|
||||
/*
|
||||
PTZ is intentionally treated as a virtual rover for chat identity only. It
|
||||
does not live in roverManager.rovers because movement, video authorization,
|
||||
and queue ownership are PTZ-service concerns, but chat needs one stable
|
||||
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
|
||||
code can all render the same badge without learning PTZ internals.
|
||||
*/
|
||||
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
|
||||
}
|
||||
|
||||
function isPublicChatTargetId(roverId, socket = null) {
|
||||
if (!roverId) return false;
|
||||
/*
|
||||
Normal rovers remain governed by the existing replay visibility rule, which
|
||||
is also the rule chat historically used to avoid exposing closed private
|
||||
rover activity. PTZ gets an explicit allow-list entry here because it is a
|
||||
public chat target that deliberately pretends to be a rover, even though it
|
||||
is not a roverManager record.
|
||||
*/
|
||||
if (isPtzChatTargetId(roverId)) return true;
|
||||
return roverManager.canReplayRoverId(roverId, socket) === true;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
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) {
|
||||
@@ -100,6 +148,7 @@ function buildRoverCtxSnapshot(roverId) {
|
||||
function buildMessage(socket, text, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const roverName = meta.roverName ?? resolveRoverName(roverId);
|
||||
const toolCalls = Array.isArray(meta.toolCalls)
|
||||
? meta.toolCalls
|
||||
.map((entry) => {
|
||||
@@ -122,6 +171,7 @@ function buildMessage(socket, text, meta = {}) {
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverName,
|
||||
roverColor,
|
||||
fromDiscord: Boolean(meta.fromDiscord),
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
@@ -143,6 +193,7 @@ function buildMessage(socket, text, meta = {}) {
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const roverName = meta.roverName ?? resolveRoverName(roverId);
|
||||
const socketId = socket?.id || null;
|
||||
const fromDiscord = Boolean(meta.fromDiscord);
|
||||
let typingId = meta.typingId || null;
|
||||
@@ -165,6 +216,7 @@ function buildTypingPayload(socket, meta = {}) {
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverName,
|
||||
roverColor,
|
||||
fromDiscord,
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
@@ -179,6 +231,8 @@ function buildTypingPayload(socket, meta = {}) {
|
||||
|
||||
module.exports = {
|
||||
resolveRoverId,
|
||||
isPtzChatTargetId,
|
||||
isPublicChatTargetId,
|
||||
isPrivateClosedRoverId,
|
||||
buildRoverCtxSnapshot,
|
||||
buildMessage,
|
||||
|
||||
@@ -7,6 +7,7 @@ const { getRole } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const { getAdminReason } = require('../adminReasonService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const {
|
||||
TYPING_NOTE_DURATION,
|
||||
ACCESS_NOTICE_COOLDOWN_MS,
|
||||
@@ -18,6 +19,13 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
/*
|
||||
PTZ borrows the roverId field for chat badges, but it has no rover command
|
||||
channel. Skipping the song command here keeps PTZ chat from producing noisy
|
||||
"unknown rover" command attempts while still allowing the message itself to
|
||||
behave like rover chat everywhere else.
|
||||
*/
|
||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
@@ -83,6 +91,22 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
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 ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
|
||||
@@ -9,6 +9,7 @@ const { getActiveDrivers } = require('../turnService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const {
|
||||
listVerifiedUsers,
|
||||
removeVerifiedUser,
|
||||
@@ -162,6 +163,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
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,
|
||||
getGuildConfig: () => null,
|
||||
setGuildConfig: () => null,
|
||||
removeGuildConfig: () => null,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
// Scope: Manages bridge channel, mode, and webhook provisioning.
|
||||
const { PermissionsBitField } = require('discord.js');
|
||||
|
||||
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser }) {
|
||||
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, discordConfig }) {
|
||||
// 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 commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
function canManageBridge(message) {
|
||||
if (isAdminUser(message.author.id)) return true;
|
||||
if (!message.guild || !message.member) return false;
|
||||
@@ -45,7 +48,7 @@ function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig
|
||||
|
||||
// Every command below this point mutates the guild bridge configuration.
|
||||
// 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`.
|
||||
if (!canManageBridge(message)) return message.reply({ content: 'You need Manage Server permissions to change the chat bridge.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
|
||||
@@ -64,14 +67,14 @@ function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig
|
||||
|
||||
if (action === 'mode') {
|
||||
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);
|
||||
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 });
|
||||
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 } });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
// Scope: Supports list, ban, and unban subcommands.
|
||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||
|
||||
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions }) {
|
||||
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) {
|
||||
// Moderation errors often get copied into Discord chat, so they should show
|
||||
// the configured bot prefix instead of the legacy default when several bots
|
||||
// are present in the same server.
|
||||
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
|
||||
return async function handleDeterCommand(message, tokens) {
|
||||
if (!isLockdownAdminUser(message.author?.id)) {
|
||||
@@ -19,7 +23,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
||||
}
|
||||
if (action === 'ban') {
|
||||
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 {
|
||||
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
||||
@@ -37,7 +41,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
||||
}
|
||||
if (action === 'unban') {
|
||||
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 {
|
||||
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
|
||||
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
@@ -47,7 +51,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
||||
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 } });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
// Discord Help Command
|
||||
// Purpose: Provides help text for rover bot Discord commands.
|
||||
// Scope: Returns static command usage text.
|
||||
function formatHelp() {
|
||||
// Scope: Returns usage text with the configured command names for this bot instance.
|
||||
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts' } = {}) {
|
||||
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
|
||||
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
|
||||
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');
|
||||
`\`${prefix} help\` — show this help`,
|
||||
`\`${prefix} status [rover]\` — show rover status; rover names can be fuzzy`,
|
||||
`\`${prefix} replay [sources]\` — send instant replay; source names can be fuzzy`,
|
||||
`\`${prefix} bridge\` — show chat bridge status for this server`,
|
||||
`\`${prefix} bridge here <global|private>\` — set chat bridge to this channel`,
|
||||
`\`${prefix} bridge mode <global|private>\` — change chat bridge mode`,
|
||||
`\`${prefix} bridge off\` — disable chat bridge for this server`,
|
||||
`\`${prefix} lights <status|lock|unlock>\` — show or change room light lock state`,
|
||||
`\`${prefix} kick <user> [reason]\` — remove a user from their current rover; use \`user | reason\` for multi-word names`,
|
||||
`\`${prefix} lock <rover>\` — lock a rover; rover names can be fuzzy`,
|
||||
`\`${prefix} unlock <rover>\` — unlock a rover; rover names can be fuzzy`,
|
||||
`\`${prefix} mode <open|turns|admin|lockdown>\` — change server mode`,
|
||||
`\`${prefix} reason [text|clear]\` — show or set admin mode reason`,
|
||||
`\`${prefix} goal [text|clear]\` — show or set global objective`,
|
||||
`\`${prefix} verify list\` — list verified users (lockdown admins)`,
|
||||
`\`${prefix} verify remove <cookieUserId|nickname>\` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)`,
|
||||
`\`${prefix} deter list\` — list deterred users (lockdown admins)`,
|
||||
`\`${prefix} deter ban <cookieUserId|nickname|ip>\` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)`,
|
||||
`\`${prefix} deter unban <id|cookieUserId|nickname|ip>\` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)`,
|
||||
timeCommand ? `\`${timeCommand}\` — show time status` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
module.exports = { formatHelp };
|
||||
|
||||
@@ -12,6 +12,8 @@ const { createVerifyCommand } = require('./verify');
|
||||
const { createDeterCommand } = require('./deter');
|
||||
const { createBridgeCommand } = require('./bridge');
|
||||
const { createTimeStatusCommand } = require('./timeStatus');
|
||||
const { createLightsCommand } = require('./lights');
|
||||
const { createKickCommand } = require('./kick');
|
||||
|
||||
function createCommandHandlers(deps) {
|
||||
const {
|
||||
@@ -20,6 +22,21 @@ function createCommandHandlers(deps) {
|
||||
isAdminUser,
|
||||
isLockdownAdminUser,
|
||||
} = deps;
|
||||
// Each running rover server can bring its own Discord bot into the same
|
||||
// guild, so the primary command prefix must come from config instead of
|
||||
// being hard-coded globally. The fallback preserves existing installs.
|
||||
const commandPrefix = String(deps.discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
// 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.
|
||||
const timeStatusCommand = deps.discordConfig?.timeStatusCommand === null
|
||||
? ''
|
||||
: String(deps.discordConfig?.timeStatusCommand || 'ts').trim();
|
||||
// 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 handleStatusCommand = createStatusCommand(deps);
|
||||
const handleReplayCommand = deps.createReplayTextCommand
|
||||
@@ -33,6 +50,22 @@ function createCommandHandlers(deps) {
|
||||
const handleDeterCommand = createDeterCommand(deps);
|
||||
const handleBridgeCommand = createBridgeCommand(deps);
|
||||
const handleTimeStatusCommand = createTimeStatusCommand(deps);
|
||||
const handleLightsCommand = createLightsCommand(deps);
|
||||
const handleKickCommand = createKickCommand(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(message) {
|
||||
if (message.author.bot) return;
|
||||
@@ -42,17 +75,22 @@ function createCommandHandlers(deps) {
|
||||
// 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;
|
||||
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand(message);
|
||||
|
||||
const tokens = content.split(/\s+/);
|
||||
tokens.shift();
|
||||
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 = isAdminUser(message.author.id);
|
||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
||||
const mode = getMode();
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter']);
|
||||
// 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']);
|
||||
|
||||
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 } });
|
||||
@@ -69,11 +107,15 @@ function createCommandHandlers(deps) {
|
||||
case 'status':
|
||||
return handleStatusCommand(message, rest);
|
||||
case 'help':
|
||||
return message.reply(formatHelp());
|
||||
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
|
||||
case 'replay':
|
||||
return handleReplayCommand(message, tokens.join(' '));
|
||||
case 'bridge':
|
||||
return handleBridgeCommand(message, tokens);
|
||||
case 'lights':
|
||||
return handleLightsCommand(message, tokens);
|
||||
case 'kick':
|
||||
return handleKickCommand(message, rest);
|
||||
case 'lock':
|
||||
return handleLockCommand(message, rest, true);
|
||||
case 'unlock':
|
||||
@@ -89,7 +131,7 @@ function createCommandHandlers(deps) {
|
||||
case 'deter':
|
||||
return handleDeterCommand(message, tokens);
|
||||
default:
|
||||
return message.reply(formatHelp());
|
||||
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Discord 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 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, discordConfig }) {
|
||||
// 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 commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
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.author?.id || null,
|
||||
});
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createKickCommand,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// Discord 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.
|
||||
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, discordConfig }) {
|
||||
// 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 commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
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, lock-on still forces configured lights to
|
||||
// white where possible, 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}`,
|
||||
forceApply: true,
|
||||
});
|
||||
|
||||
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 };
|
||||
@@ -1,12 +1,15 @@
|
||||
// Discord 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.
|
||||
const { resolveRoverSelector } = require('./resolvers');
|
||||
|
||||
function createLockCommand({ lockRover, sanitizeMentions, rovers }) {
|
||||
function createLockCommand({ lockRover, sanitizeMentions, rovers, discordConfig }) {
|
||||
// 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 commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
return async function handleLockCommand(message, roverId, locked) {
|
||||
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;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Discord Mode Command
|
||||
// Purpose: Handles `rs mode` updates from Discord admins.
|
||||
// Purpose: Handles mode updates from Discord admins through the configured bot prefix.
|
||||
// Scope: Validates mode values and applies mode changes with optional reason text.
|
||||
function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) {
|
||||
return async function handleModeCommand(message, tokens = []) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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.
|
||||
const { EmbedBuilder } = require('discord.js');
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
// Scope: Supports list and remove subcommands.
|
||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||
|
||||
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions }) {
|
||||
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) {
|
||||
// Verification usage text follows the configured prefix for the same reason
|
||||
// as the command router: each bot instance needs its own command namespace.
|
||||
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
|
||||
return async function handleVerifyCommand(message, tokens) {
|
||||
if (!isLockdownAdminUser(message.author?.id)) {
|
||||
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
@@ -18,7 +21,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
||||
}
|
||||
if (action === 'remove') {
|
||||
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 {
|
||||
const resolved = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
@@ -32,7 +35,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
|
||||
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 } });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const { getActiveDrivers } = require('../turnService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const {
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
@@ -53,6 +54,15 @@ const { createIntegrations } = require('./integrations');
|
||||
const config = loadConfig();
|
||||
const discordConfig = config.discord || {};
|
||||
const enabled = Boolean(discordConfig.token);
|
||||
// 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 commandPrefix = String(discordConfig.commandPrefix || 'rs').trim() || 'rs';
|
||||
const timeStatusCommand = discordConfig.timeStatusCommand === null
|
||||
? ''
|
||||
: String(discordConfig.timeStatusCommand || 'ts').trim();
|
||||
const normalizedCommandPrefix = commandPrefix.toLowerCase();
|
||||
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
|
||||
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));
|
||||
|
||||
@@ -136,6 +146,11 @@ const commands = createCommandHandlers({
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
// Room-light lock commands must use the same Home Assistant service instance
|
||||
// as sockets, HA button triggers, and idle/darkness policies. Passing the
|
||||
// service into the shared command router keeps Discord and mirrored web-chat
|
||||
// command behavior aligned without duplicating Home Assistant calls here.
|
||||
homeAssistantService,
|
||||
getGuildConfig,
|
||||
setGuildConfig,
|
||||
removeGuildConfig,
|
||||
@@ -195,7 +210,15 @@ const integrationHandlers = integrations.register();
|
||||
|
||||
function isTextCommand(content) {
|
||||
const clean = String(content || '').trim();
|
||||
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
|
||||
const lower = clean.toLowerCase();
|
||||
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return true;
|
||||
if (!lower.startsWith(normalizedCommandPrefix)) return false;
|
||||
|
||||
const nextCharacter = clean.charAt(commandPrefix.length);
|
||||
// Mirrored bridge commands must use the exact same whole-token prefix rule
|
||||
// as the command router. If this check is looser than the router, normal
|
||||
// bridge chat can be wrapped as a command reply even though no command runs.
|
||||
return !nextCharacter || /\s/.test(nextCharacter);
|
||||
}
|
||||
|
||||
function isBridgeChannelMessage(message) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Bridges chat and typing between Discord and site sockets.
|
||||
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
|
||||
const { WebhookClient } = require('discord.js');
|
||||
const { isPublicChatTargetId } = require('../../chatService/contextBuilders');
|
||||
|
||||
function summarizeToolCall(entry = {}) {
|
||||
const tool = String(entry?.tool || 'unknown');
|
||||
@@ -23,7 +24,6 @@ function createChatBridgeHandlers(deps) {
|
||||
const {
|
||||
logger,
|
||||
client,
|
||||
roverManager,
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
sendExternalMessage,
|
||||
@@ -59,7 +59,13 @@ function createChatBridgeHandlers(deps) {
|
||||
function handleChatBridgeOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
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();
|
||||
if (!guildConfigs.length) return;
|
||||
|
||||
@@ -96,7 +102,11 @@ function createChatBridgeHandlers(deps) {
|
||||
function handleChatTypingOutbound(event) {
|
||||
const payload = event?.payload;
|
||||
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();
|
||||
if (!guildConfigs.length) return;
|
||||
|
||||
|
||||
@@ -24,7 +24,14 @@ function formatWebhookUsername(payload) {
|
||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Exposes stable room-control APIs while delegating internals to focused modules.
|
||||
const logger = require('../../globals/logger').child('homeAssistantService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { events } = require('./state');
|
||||
const { createRuntimeEngine } = require('./runtimeEngine');
|
||||
const { createTransport } = require('./transport');
|
||||
@@ -10,7 +11,7 @@ const { registerHomeAssistantHooks } = require('./hooks');
|
||||
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const enabled = Boolean(haConfig?.url && haConfig?.token);
|
||||
const enabled = isFeatureEnabled('homeAssistant');
|
||||
|
||||
let callHomeAssistantServiceImpl = async () => {
|
||||
throw new Error('Home Assistant not connected');
|
||||
@@ -35,18 +36,33 @@ callHomeAssistantServiceImpl = transport.callHomeAssistantService;
|
||||
|
||||
runtimeEngine.loadEntityConfig();
|
||||
runtimeEngine.loadTriggerConfig();
|
||||
transport.connect();
|
||||
|
||||
registerHomeAssistantHooks({
|
||||
logger,
|
||||
haConfig,
|
||||
isLightControlLocked: runtimeEngine.isLightControlLocked,
|
||||
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||
toggleEntity: runtimeEngine.toggleEntity,
|
||||
setEntityState: runtimeEngine.setEntityState,
|
||||
setLightColor: runtimeEngine.setLightColor,
|
||||
setLightWhite: runtimeEngine.setLightWhite,
|
||||
});
|
||||
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,
|
||||
haConfig,
|
||||
isLightControlLocked: runtimeEngine.isLightControlLocked,
|
||||
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||
toggleEntity: runtimeEngine.toggleEntity,
|
||||
setEntityState: runtimeEngine.setEntityState,
|
||||
setLightColor: runtimeEngine.setLightColor,
|
||||
setLightWhite: runtimeEngine.setLightWhite,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getState: runtimeEngine.getState,
|
||||
|
||||
@@ -7,6 +7,7 @@ const { issueCommand } = require('../commandService');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const neatoService = require('../neatoService');
|
||||
const liftService = require('../liftService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const {
|
||||
HEADLIGHT_DISABLE_ACTION,
|
||||
LASER_DISABLE_ACTION,
|
||||
@@ -103,6 +104,17 @@ async function disableAllRoverLasers() {
|
||||
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() {
|
||||
try {
|
||||
await neatoService.sendHome();
|
||||
@@ -126,6 +138,7 @@ const idleActions = [
|
||||
// dockAllRovers,
|
||||
disableAllRoverHeadlights,
|
||||
disableAllRoverLasers,
|
||||
disablePtzEmitters,
|
||||
sendNeatoHome,
|
||||
raiseLift,
|
||||
];
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
// Idle Service
|
||||
// Purpose: Triggers a modular idle action pipeline after a sustained no-driver period.
|
||||
// Scope: Observes driver activity events and coordinates timer-based idle automation execution.
|
||||
// Purpose: Triggers a modular idle action pipeline after a sustained no-operator-online period.
|
||||
// Scope: Observes user/admin socket presence and coordinates timer-based idle automation execution.
|
||||
const logger = require('../../globals/logger').child('idleService');
|
||||
const { getActiveDrivers, turnEvents } = require('../turnService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRecentDriveActivity } = require('../commandService');
|
||||
const io = require('../../globals/io');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { IDLE_TIMEOUT_MS } = require('./constants');
|
||||
const { runtime } = require('./state');
|
||||
const { runIdleActions } = require('./actions');
|
||||
|
||||
function getActivitySnapshot() {
|
||||
const active = getActiveDrivers();
|
||||
const turnCount = active && typeof active === 'object' ? Object.keys(active).length : 0;
|
||||
const activeByTurn = turnCount;
|
||||
let onlineUsers = 0;
|
||||
let onlineAdmins = 0;
|
||||
let onlineSpectators = 0;
|
||||
let onlineIgnored = 0;
|
||||
|
||||
let liveCount = 0;
|
||||
roverManager.rovers.forEach((record) => {
|
||||
if (record?.drivers?.size > 0) liveCount += 1;
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
const role = getRole(socket);
|
||||
|
||||
/*
|
||||
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 activeByRecentDrive = recentDriveEvents.length;
|
||||
|
||||
const totalActive = Math.max(activeByTurn, activeByRoverDrivers, activeByRecentDrive);
|
||||
const totalActive = onlineUsers + onlineAdmins;
|
||||
return {
|
||||
activeByTurn,
|
||||
activeByRoverDrivers,
|
||||
activeByRecentDrive,
|
||||
onlineUsers,
|
||||
onlineAdmins,
|
||||
onlineSpectators,
|
||||
onlineIgnored,
|
||||
totalActive,
|
||||
};
|
||||
}
|
||||
@@ -53,7 +83,7 @@ function scheduleIdleTimer() {
|
||||
runtime.deadlineAt = null;
|
||||
const activity = getActivitySnapshot();
|
||||
if (activity.totalActive > 0) {
|
||||
logger.info('Idle automation skipped; active control detected', activity);
|
||||
logger.info('Idle automation skipped; user or admin online', activity);
|
||||
return;
|
||||
}
|
||||
runtime.lastTriggeredAt = Date.now();
|
||||
@@ -77,8 +107,18 @@ function refreshIdleState() {
|
||||
scheduleIdleTimer();
|
||||
}
|
||||
|
||||
turnEvents.on('activeDriver', refreshIdleState);
|
||||
turnEvents.on('queue', refreshIdleState);
|
||||
io.on('connection', (socket) => {
|
||||
/*
|
||||
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();
|
||||
|
||||
|
||||
@@ -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,6 +5,7 @@ const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('liftService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isLockdownAdmin } = require('../roleService');
|
||||
const {
|
||||
@@ -19,6 +20,7 @@ const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const liftConfig = haConfig.lift || {};
|
||||
const featureEnabled = isFeatureEnabled('lift');
|
||||
|
||||
const upSwitchId = String(liftConfig.upSwitch || '').trim();
|
||||
const downSwitchId = String(liftConfig.downSwitch || '').trim();
|
||||
@@ -71,7 +73,7 @@ function getState() {
|
||||
const configured = isConfigured();
|
||||
const connected = isHomeAssistantConnected();
|
||||
return {
|
||||
enabled: Boolean(homeAssistantEnabled && configured),
|
||||
enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
|
||||
configured,
|
||||
connected,
|
||||
entities: {
|
||||
@@ -102,6 +104,7 @@ function emitUpdate() {
|
||||
}
|
||||
|
||||
function assertReady() {
|
||||
if (!featureEnabled) throw new Error('Lift is disabled');
|
||||
if (!isConfigured()) throw new Error('Lift not configured');
|
||||
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
|
||||
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
|
||||
@@ -173,38 +176,47 @@ async function moveDown(actor = 'unknown') {
|
||||
return requestPosition('down', actor);
|
||||
}
|
||||
|
||||
homeAssistantEvents.on('snapshot', emitUpdate);
|
||||
homeAssistantEvents.on('status', emitUpdate);
|
||||
if (featureEnabled) {
|
||||
/*
|
||||
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) => {
|
||||
socket.on('lift:up', async (_, cb = () => {}) => {
|
||||
try {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
throw new Error('Server in lockdown');
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('lift:up', async (_, cb = () => {}) => {
|
||||
try {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
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');
|
||||
cb({ success: true, ...resp });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
// 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');
|
||||
cb({ success: true, ...resp });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('lift:down', async (_, cb = () => {}) => {
|
||||
try {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
throw new Error('Server in lockdown');
|
||||
socket.on('lift:down', async (_, cb = () => {}) => {
|
||||
try {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
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');
|
||||
cb({ success: true, ...resp });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
// 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');
|
||||
cb({ success: true, ...resp });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.info('Lift disabled by config');
|
||||
}
|
||||
|
||||
emitUpdate();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// llm Commentary Service snapshot engine
|
||||
// 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.
|
||||
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
|
||||
|
||||
function createSnapshotEngine(deps) {
|
||||
const {
|
||||
io,
|
||||
@@ -368,7 +370,12 @@ function createSnapshotEngine(deps) {
|
||||
.filter((entry) => {
|
||||
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
||||
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
|
||||
.filter((entry) => !entry?.bot)
|
||||
|
||||
@@ -5,6 +5,7 @@ const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('neatoService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isLockdownAdmin } = require('../roleService');
|
||||
@@ -20,6 +21,7 @@ const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const haConfig = config.homeAssistant || {};
|
||||
const neatoConfig = haConfig.neato || {};
|
||||
const featureEnabled = isFeatureEnabled('neato');
|
||||
|
||||
function normalizeDeviceName(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
@@ -117,7 +119,7 @@ function buildState() {
|
||||
const requiredIds = requiredEntityIds();
|
||||
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
|
||||
const connected = Boolean(haConnected && entitiesAvailable);
|
||||
const enabled = Boolean(homeAssistantEnabled && configured);
|
||||
const enabled = Boolean(featureEnabled && homeAssistantEnabled && configured);
|
||||
|
||||
const controls = {
|
||||
start: {
|
||||
@@ -189,15 +191,25 @@ function emitUpdate() {
|
||||
}
|
||||
}
|
||||
|
||||
homeAssistantEvents.on('snapshot', () => {
|
||||
emitUpdate();
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
homeAssistantEvents.on('status', () => {
|
||||
emitUpdate();
|
||||
});
|
||||
homeAssistantEvents.on('status', () => {
|
||||
emitUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
function assertConfiguredAndConnected() {
|
||||
if (!featureEnabled) {
|
||||
throw new Error('Neato is disabled');
|
||||
}
|
||||
if (!device) {
|
||||
throw new Error('Neato not configured');
|
||||
}
|
||||
@@ -255,7 +267,8 @@ function hasVerifiedSockets() {
|
||||
return false;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
if (featureEnabled) {
|
||||
io.on('connection', (socket) => {
|
||||
function assertLockdownAccess() {
|
||||
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||
throw new Error('Server in lockdown');
|
||||
@@ -319,7 +332,10 @@ io.on('connection', (socket) => {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.info('Neato disabled by config');
|
||||
}
|
||||
|
||||
emitUpdate();
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ function ensureLoaded() {
|
||||
lastSampleAt: null,
|
||||
lastIntegratedAt: null,
|
||||
lastDelta: null,
|
||||
wheelSpeedsMmPerSecond: {
|
||||
left: null,
|
||||
right: null,
|
||||
center: null,
|
||||
},
|
||||
rolloverEvents: 0,
|
||||
ignoredSamples: 0,
|
||||
status: 'waiting',
|
||||
@@ -96,6 +101,11 @@ function ensureState(roverId) {
|
||||
lastSampleAt: null,
|
||||
lastIntegratedAt: null,
|
||||
lastDelta: null,
|
||||
wheelSpeedsMmPerSecond: {
|
||||
left: null,
|
||||
right: null,
|
||||
center: null,
|
||||
},
|
||||
rolloverEvents: 0,
|
||||
ignoredSamples: 0,
|
||||
status: 'waiting',
|
||||
@@ -187,6 +197,11 @@ function snapshotState(state) {
|
||||
lastSampleAt: state.lastSampleAt,
|
||||
lastIntegratedAt: state.lastIntegratedAt,
|
||||
lastDelta: state.lastDelta,
|
||||
wheelSpeedsMmPerSecond: state.wheelSpeedsMmPerSecond || {
|
||||
left: null,
|
||||
right: null,
|
||||
center: null,
|
||||
},
|
||||
rolloverEvents: state.rolloverEvents,
|
||||
ignoredSamples: state.ignoredSamples,
|
||||
status: state.status,
|
||||
@@ -228,6 +243,16 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
if (!Number.isInteger(left) || !Number.isInteger(right)) {
|
||||
state.status = 'waiting';
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -241,6 +266,16 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
state.lastSampleAt = sampleAt;
|
||||
state.status = 'tracking';
|
||||
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;
|
||||
const snapshot = snapshotState(state);
|
||||
emitSnapshot(state, snapshot, { force: true });
|
||||
@@ -258,6 +293,30 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
const reasonableLimit = maxReasonableDeltaMm(elapsedMs);
|
||||
const leftRolled = crossedRollover(state.lastLeftCount, left, leftCounts);
|
||||
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.lastRightCount = right;
|
||||
@@ -272,6 +331,16 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
state.ignoredSamples += 1;
|
||||
state.status = 'ignored';
|
||||
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 = {
|
||||
leftCounts,
|
||||
rightCounts,
|
||||
@@ -280,6 +349,9 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
centerMm: Math.round(centerMm),
|
||||
distanceMm: 0,
|
||||
elapsedMs,
|
||||
leftSpeedMmPerSecond: null,
|
||||
rightSpeedMmPerSecond: null,
|
||||
centerSpeedMmPerSecond: null,
|
||||
ignored: true,
|
||||
};
|
||||
state.updatedAt = sampleAt;
|
||||
@@ -293,6 +365,7 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
state.lastIntegratedAt = sampleAt;
|
||||
state.status = 'tracking';
|
||||
state.statusReason = distanceMm > 0 ? 'integrated encoder delta' : 'no movement';
|
||||
state.wheelSpeedsMmPerSecond = wheelSpeedsMmPerSecond;
|
||||
state.lastDelta = {
|
||||
leftCounts,
|
||||
rightCounts,
|
||||
@@ -301,6 +374,9 @@ function processSensorFrame(roverId, sensors = {}) {
|
||||
centerMm: Math.round(centerMm),
|
||||
distanceMm: Math.round(distanceMm),
|
||||
elapsedMs,
|
||||
leftSpeedMmPerSecond: wheelSpeedsMmPerSecond.left,
|
||||
rightSpeedMmPerSecond: wheelSpeedsMmPerSecond.right,
|
||||
centerSpeedMmPerSecond: wheelSpeedsMmPerSecond.center,
|
||||
ignored: false,
|
||||
};
|
||||
state.updatedAt = sampleAt;
|
||||
|
||||
@@ -15,6 +15,7 @@ const { getState: getNeatoState, neatoEvents } = neatoService;
|
||||
const { getState: getLiftState, liftEvents } = liftService;
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
||||
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const {
|
||||
PROMPT_PATH,
|
||||
@@ -378,7 +379,12 @@ async function runDecision(triggerReason) {
|
||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||
.filter((entry) => {
|
||||
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));
|
||||
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
@@ -49,7 +49,7 @@ const replayBuilder = createReplayBuilder({
|
||||
ensureDir,
|
||||
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
||||
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
||||
getAudioEntriesForRover: segmentStore.getAudioEntriesForRover,
|
||||
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
||||
overlapping: segmentStore.overlapping,
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) {
|
||||
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||
const tentativeEnd = nowMs - guardMs;
|
||||
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]);
|
||||
}
|
||||
|
||||
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) {
|
||||
let maxWidth = 0, maxHeight = 0;
|
||||
for (const filePath of paths) {
|
||||
@@ -108,7 +135,11 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
||||
for (let i = 0; i < sources.length; i += 1) {
|
||||
const source = sources[i];
|
||||
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; }
|
||||
|
||||
const videoConcat = path.join(tmpDir, `video-${i}.mp4`);
|
||||
@@ -122,18 +153,26 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
||||
normalizedVideos.push({ path: videoTrimmed, source });
|
||||
usedSources.push(source);
|
||||
|
||||
if (source.type === 'rover') {
|
||||
const audioEntries = overlapping(getAudioEntriesForRover(sourceId), tStart, tEnd);
|
||||
if (audioEntries.length) {
|
||||
const audioConcat = path.join(tmpDir, `audio-${i}.m4a`);
|
||||
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
|
||||
const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`);
|
||||
const firstAudioStartMs = audioEntries[0].startMs;
|
||||
const ass = Math.max(0, (tStart - firstAudioStartMs) / 1000);
|
||||
const ato = Math.max(ass + 0.1, (tEnd - firstAudioStartMs) / 1000);
|
||||
await execFileAsync(FFMPEG_BIN, ['-y','-hide_banner','-loglevel','error','-ss',ass.toFixed(3),'-to',ato.toFixed(3),'-i',audioConcat,'-vn','-ac','1','-ar','48000','-c:a','aac','-b:a','96k',audioTrimmed]);
|
||||
normalizedAudios.push(audioTrimmed);
|
||||
}
|
||||
const audioEntries = await pinSegmentFiles(
|
||||
overlapping(getAudioEntriesForSource(source), tStart, tEnd),
|
||||
tmpDir,
|
||||
`audio-${i}-seg`,
|
||||
);
|
||||
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`);
|
||||
await concatFiles(audioEntries.map((entry) => entry.filePath), audioConcat);
|
||||
const audioTrimmed = path.join(tmpDir, `audio-${i}.trim.m4a`);
|
||||
const firstAudioStartMs = audioEntries[0].startMs;
|
||||
const ass = Math.max(0, (tStart - firstAudioStartMs) / 1000);
|
||||
const ato = Math.max(ass + 0.1, (tEnd - firstAudioStartMs) / 1000);
|
||||
await execFileAsync(FFMPEG_BIN, ['-y','-hide_banner','-loglevel','error','-ss',ass.toFixed(3),'-to',ato.toFixed(3),'-i',audioConcat,'-vn','-ac','1','-ar','48000','-c:a','aac','-b:a','96k',audioTrimmed]);
|
||||
normalizedAudios.push(audioTrimmed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Handles user-visible replay source catalogs and default source selection rules.
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
|
||||
function getReplaySources(socket = null) {
|
||||
const roster = socket ? roverManager.getRosterForSocket(socket) : roverManager.getRoster();
|
||||
@@ -21,7 +22,10 @@ function getReplaySources(socket = null) {
|
||||
label: camera.name || camera.id,
|
||||
}));
|
||||
|
||||
return [...roverSources, ...roomSources];
|
||||
const ptzSource = ptzCameraService.getReplaySource();
|
||||
const ptzSources = ptzSource ? [ptzSource] : [];
|
||||
|
||||
return [...roverSources, ...roomSources, ...ptzSources];
|
||||
}
|
||||
|
||||
function normalizeSource(entry) {
|
||||
@@ -67,11 +71,14 @@ function getDefaultWebSources(assignment = {}, socket = null) {
|
||||
}
|
||||
|
||||
function getDefaultDiscordSources() {
|
||||
return getRoomCameras().map((camera) => ({
|
||||
const sources = getRoomCameras().map((camera) => ({
|
||||
type: 'room',
|
||||
id: String(camera.id),
|
||||
label: camera.name || camera.id,
|
||||
}));
|
||||
const ptzSource = ptzCameraService.getReplaySource();
|
||||
if (ptzSource) sources.push(ptzSource);
|
||||
return sources;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -8,6 +8,7 @@ const logger = require('../../globals/logger').child('replayEngineV2');
|
||||
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
||||
const { workers, segmentIndex } = require('./state');
|
||||
const { sourceKey, sourceDirForKey } = require('./sources');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
@@ -96,6 +97,24 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
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) {
|
||||
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
||||
}
|
||||
@@ -123,6 +142,8 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
for (const camera of getRoomCameras()) {
|
||||
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) {
|
||||
const key = sourceKey({ sourceType: source.type, kind: 'video', id: String(source.id) });
|
||||
@@ -154,6 +175,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
cleanupOldFiles,
|
||||
getVideoEntriesForSource,
|
||||
getAudioEntriesForRover,
|
||||
getAudioEntriesForSource,
|
||||
overlapping,
|
||||
bootstrapIndexFromDisk,
|
||||
getReplayHealthSnapshot,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const path = require('path');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
|
||||
|
||||
function sourceKey(source) {
|
||||
@@ -46,6 +47,10 @@ function listDesiredSources() {
|
||||
if (!streamUrl) continue;
|
||||
sources.push({ id: String(camera.id), sourceType: 'room', kind: 'video', label: camera.name || camera.id, inputUrl: streamUrl });
|
||||
}
|
||||
// The PTZ service owns its own worker list because video and microphone audio
|
||||
// both come from the same live MediaMTX path, unlike rovers where audio is a
|
||||
// separate published stream.
|
||||
sources.push(...ptzCameraService.getReplayWorkerSources());
|
||||
return sources;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const EventEmitter = require('events');
|
||||
const logger = require('../../globals/logger').child('roomCameraService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getRoomCameraEntries } = require('../../helpers/features');
|
||||
|
||||
const events = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
@@ -40,7 +41,7 @@ function getRoomCamera(id) {
|
||||
|
||||
function loadFromConfig() {
|
||||
cameraMap.clear();
|
||||
const list = Array.isArray(config.roomCameras) ? config.roomCameras : [];
|
||||
const list = getRoomCameraEntries(config);
|
||||
list.forEach((camera) => {
|
||||
const normalized = normalizeCamera(camera);
|
||||
if (normalized) cameraMap.set(normalized.id, normalized);
|
||||
|
||||
@@ -5,18 +5,34 @@ const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = requ
|
||||
const { createSnapshotEngine } = require('./snapshotEngine');
|
||||
const { registerRoomCameraSocketGateway } = require('./socketGateway');
|
||||
const replay = require('../replayEngineV2/roomCameraReplayBuilder');
|
||||
const { isFeatureEnabled } = require('../../helpers/features');
|
||||
|
||||
const enabled = isFeatureEnabled('roomCameras');
|
||||
|
||||
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
||||
snapshotEngine.startAll();
|
||||
if (enabled) {
|
||||
/*
|
||||
Room cameras are optional local hardware/network devices. The service module
|
||||
can still be imported by replay, health, and session code, but disabled
|
||||
installs must not start polling LAN cameras in the background.
|
||||
*/
|
||||
loadFromConfig();
|
||||
snapshotEngine.startAll();
|
||||
}
|
||||
|
||||
registerRoomCameraSocketGateway({
|
||||
getRoomCamera,
|
||||
getRoomCameras,
|
||||
getRoomCameraState: snapshotEngine.getRoomCameraState,
|
||||
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
|
||||
});
|
||||
|
||||
loadFromConfig();
|
||||
if (enabled) {
|
||||
/*
|
||||
Camera frame sockets are part of the room-camera feature surface. Keeping
|
||||
them behind the same gate prevents disabled features from being callable by
|
||||
hand even though server/index.js still imports this module.
|
||||
*/
|
||||
registerRoomCameraSocketGateway({
|
||||
getRoomCamera,
|
||||
getRoomCameras,
|
||||
getRoomCameraState: snapshotEngine.getRoomCameraState,
|
||||
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
|
||||
});
|
||||
}
|
||||
|
||||
function buildRoomCameraReplayVideo(options = {}) {
|
||||
return replay.buildRoomCameraReplayVideo(options, { getRoomCamera, getRoomCameras });
|
||||
|
||||
@@ -111,11 +111,11 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
|
||||
if (!passesMode(socket)) throw new Error('Not authorized for room camera');
|
||||
const validIds = uniqueIds.filter((id) => !!getRoomCamera(id));
|
||||
validIds.forEach((cameraId) => addSubscription(socket, cameraId));
|
||||
validIds.forEach((cameraId) => {
|
||||
const state = getRoomCameraState(cameraId);
|
||||
if (state?.frame) sendFrame(socket, cameraId, { ts: state.ts }, state.frame);
|
||||
sendStatus(socket, cameraId, { ts: state?.ts || null, error: state?.error || null });
|
||||
});
|
||||
// validIds.forEach((cameraId) => {
|
||||
// const state = getRoomCameraState(cameraId);
|
||||
// if (state?.frame) sendFrame(socket, cameraId, { ts: state.ts }, state.frame);
|
||||
// sendStatus(socket, cameraId, { ts: state?.ts || null, error: state?.error || null });
|
||||
// });
|
||||
cb({ ok: true, subscribed: validIds });
|
||||
} catch (err) {
|
||||
logger.warn('Room camera subscribe failed', { socketId: socket.id, err: err.message });
|
||||
|
||||
@@ -26,6 +26,13 @@ const DEFAULT_PRIVATE_SAFETY = Object.freeze({
|
||||
cliffEnabled: false,
|
||||
cliffBackoffSpeed: 250,
|
||||
cliffBackoffMs: 500,
|
||||
// Private rovers live in the sensitive area, so the virtual wall guard is
|
||||
// default-on even though the older safety features remain opt-in. A missing
|
||||
// value from an older rover config should therefore behave like "enabled",
|
||||
// not like a deliberate disabled setting.
|
||||
virtualWallEnabled: true,
|
||||
virtualWallBackoffSpeed: 250,
|
||||
virtualWallBackoffMs: 500,
|
||||
triggerCooldownMs: 800,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
managerEvents,
|
||||
backoffTimers,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
privateButtonStates,
|
||||
privateNoUsersSince,
|
||||
privateSafetyTimers,
|
||||
@@ -88,6 +89,7 @@ const {
|
||||
canDrive,
|
||||
getRoversForSocket,
|
||||
getPrimaryRoverForSocket,
|
||||
canLeaveCurrentRover,
|
||||
canSwitchRover,
|
||||
} = roverLifecycle;
|
||||
|
||||
@@ -154,6 +156,7 @@ const sensorPipeline = createSensorPipeline({
|
||||
rovers,
|
||||
managerEvents,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
backoffTimers,
|
||||
privateButtonStates,
|
||||
privateSafetyTimers,
|
||||
@@ -275,6 +278,7 @@ module.exports = {
|
||||
managerEvents,
|
||||
getRoversForSocket,
|
||||
getPrimaryRoverForSocket,
|
||||
canLeaveCurrentRover,
|
||||
canSeeRover,
|
||||
canRequestControl,
|
||||
applyPrivateDriveSafety,
|
||||
|
||||
@@ -52,6 +52,26 @@ function normalizePrivateSafety(raw = {}) {
|
||||
5000,
|
||||
DEFAULT_PRIVATE_SAFETY.cliffBackoffMs,
|
||||
),
|
||||
// Unlike most private safety toggles, virtual wall support is meant to be
|
||||
// enabled by default for private rovers. The nullish check preserves that
|
||||
// default for older rover configs while still allowing lockdown admins to
|
||||
// explicitly turn the guard off.
|
||||
virtualWallEnabled:
|
||||
source.virtualWallEnabled == null
|
||||
? DEFAULT_PRIVATE_SAFETY.virtualWallEnabled
|
||||
: Boolean(source.virtualWallEnabled),
|
||||
virtualWallBackoffSpeed: clampInt(
|
||||
source.virtualWallBackoffSpeed,
|
||||
1,
|
||||
500,
|
||||
DEFAULT_PRIVATE_SAFETY.virtualWallBackoffSpeed,
|
||||
),
|
||||
virtualWallBackoffMs: clampInt(
|
||||
source.virtualWallBackoffMs,
|
||||
100,
|
||||
5000,
|
||||
DEFAULT_PRIVATE_SAFETY.virtualWallBackoffMs,
|
||||
),
|
||||
triggerCooldownMs: clampInt(
|
||||
source.triggerCooldownMs,
|
||||
100,
|
||||
|
||||
@@ -137,6 +137,22 @@ function createRoverLifecycle(deps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function canLeaveCurrentRover(socket, options = {}) {
|
||||
const currentId = getPrimaryRoverForSocket(socket.id);
|
||||
/*
|
||||
Leaving a rover is only risky when this socket is the last person attached
|
||||
to an undocked rover. The same rule is used for rover-to-rover switching
|
||||
and PTZ camera claiming so there is exactly one definition of "do not
|
||||
abandon a rover in the room".
|
||||
*/
|
||||
if (!currentId || currentId === options.targetRoverId) return { ok: true, currentId };
|
||||
const currentRecord = rovers.get(currentId);
|
||||
if (!currentRecord) return { ok: true, currentId };
|
||||
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
|
||||
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
|
||||
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
|
||||
}
|
||||
|
||||
function canSwitchRover(socket, targetRoverId, options = {}) {
|
||||
const target = rovers.get(targetRoverId);
|
||||
if (!target) return { ok: false, message: 'Unknown rover' };
|
||||
@@ -145,13 +161,7 @@ function createRoverLifecycle(deps) {
|
||||
allowClosedPrivateGrantInLockdown: Boolean(options.allowClosedPrivateGrantInLockdown),
|
||||
});
|
||||
if (denied) return { ok: false, message: denied };
|
||||
const currentId = getPrimaryRoverForSocket(socket.id);
|
||||
if (!currentId || currentId === targetRoverId) return { ok: true, currentId };
|
||||
const currentRecord = rovers.get(currentId);
|
||||
if (!currentRecord) return { ok: true, currentId };
|
||||
if (hasOtherDrivers(currentRecord, socket.id)) return { ok: true, currentId };
|
||||
if (isDockedAndCharging(currentRecord)) return { ok: true, currentId };
|
||||
return { ok: false, currentId, message: 'Dock and charge your current rover before switching.' };
|
||||
return canLeaveCurrentRover(socket, { targetRoverId });
|
||||
}
|
||||
|
||||
function canReplayRoverId(roverId, isPrivateRecord, isPrivateOpen) {
|
||||
@@ -169,6 +179,7 @@ function createRoverLifecycle(deps) {
|
||||
canDrive,
|
||||
getRoversForSocket,
|
||||
getPrimaryRoverForSocket,
|
||||
canLeaveCurrentRover,
|
||||
canSwitchRover,
|
||||
canReplayRoverId,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ function createSensorPipeline(deps) {
|
||||
rovers,
|
||||
managerEvents,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
backoffTimers,
|
||||
privateButtonStates,
|
||||
privateSafetyTimers,
|
||||
@@ -38,6 +39,9 @@ function createSensorPipeline(deps) {
|
||||
shouldApplyPrivateSensorSafety,
|
||||
} = deps;
|
||||
|
||||
const DOCK_PROTECTION_MAX_STRIKES = 3;
|
||||
const DOCK_PROTECTION_STRIKE_RESET_MS = 5 * 60 * 1000;
|
||||
|
||||
function getPrivateSafetyState(roverId) {
|
||||
if (!privateSafetyStates.has(roverId)) {
|
||||
privateSafetyStates.set(roverId, {
|
||||
@@ -45,6 +49,8 @@ function createSensorPipeline(deps) {
|
||||
lastOvercurrent: false,
|
||||
lastBump: false,
|
||||
lastCliff: false,
|
||||
lastVirtualWall: false,
|
||||
lastDriveDirection: null,
|
||||
});
|
||||
}
|
||||
return privateSafetyStates.get(roverId);
|
||||
@@ -63,6 +69,10 @@ function createSensorPipeline(deps) {
|
||||
const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs);
|
||||
const backoffMs = clampInt(options.backoffMs, 50, 5000, 0);
|
||||
const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 0);
|
||||
const explicitBackoffDrive = options.backoffDrive && typeof options.backoffDrive === 'object'
|
||||
? options.backoffDrive
|
||||
: null;
|
||||
const notify = options.notify !== false;
|
||||
try {
|
||||
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } });
|
||||
@@ -70,10 +80,14 @@ function createSensorPipeline(deps) {
|
||||
logger.warn('Private safety stop failed', { roverId, mode, error: err.message });
|
||||
}
|
||||
stopSafetyBackoffTimer(roverId);
|
||||
if (backoffMs > 0 && backoffSpeed > 0) {
|
||||
if (backoffMs > 0 && (backoffSpeed > 0 || explicitBackoffDrive)) {
|
||||
// Bump and cliff safety only need a simple straight reverse. Virtual wall
|
||||
// safety can be hit while turning or arcing, so it may pass an explicit
|
||||
// per-wheel escape command that reverses the last commanded wheel signs.
|
||||
const speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed)));
|
||||
const backoffDrive = explicitBackoffDrive || { left: speed, right: speed };
|
||||
try {
|
||||
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } });
|
||||
issueCommand(roverId, { type: 'drive', driveDirect: backoffDrive });
|
||||
} catch (err) {
|
||||
logger.warn('Private safety backoff failed', { roverId, mode, error: err.message });
|
||||
}
|
||||
@@ -92,16 +106,62 @@ function createSensorPipeline(deps) {
|
||||
setDriveCooldown(roverId, Math.max(cooldownMs, backoffMs));
|
||||
const state = getPrivateSafetyState(roverId);
|
||||
state.blockedUntil = now + Math.max(cooldownMs, backoffMs);
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Private Safety',
|
||||
message: `${roverId} ${mode} safety triggered.`,
|
||||
});
|
||||
publishEvent({
|
||||
source: 'roverManager',
|
||||
type: 'rover.privateSafetyTriggered',
|
||||
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed },
|
||||
});
|
||||
if (notify) {
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Private Safety',
|
||||
message: `${roverId} ${mode} safety triggered.`,
|
||||
});
|
||||
publishEvent({
|
||||
source: 'roverManager',
|
||||
type: 'rover.privateSafetyTriggered',
|
||||
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed, backoffDrive: explicitBackoffDrive },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function rememberPrivateDriveDirection(roverId, driveDirect = null) {
|
||||
if (!roverId || !driveDirect || typeof driveDirect !== 'object') return;
|
||||
const left = clampInt(driveDirect.left, -500, 500, 0);
|
||||
const right = clampInt(driveDirect.right, -500, 500, 0);
|
||||
if (left === 0 && right === 0) return;
|
||||
const state = getPrivateSafetyState(String(roverId));
|
||||
// Store signs instead of raw speeds because safety escape speed is its own
|
||||
// configured value. The thing we need from the driver command is direction,
|
||||
// not magnitude, so later speed-limit changes cannot make the remembered
|
||||
// state stale or unsafe.
|
||||
state.lastDriveDirection = {
|
||||
left: Math.sign(left),
|
||||
right: Math.sign(right),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function getOppositePrivateDrive(record, speed) {
|
||||
const state = getPrivateSafetyState(record.id);
|
||||
const direction = state.lastDriveDirection || null;
|
||||
const safeSpeed = clampInt(speed, 1, 500, DEFAULT_PRIVATE_SAFETY.virtualWallBackoffSpeed);
|
||||
if (!direction) {
|
||||
// If the rover has not received a remembered drive command yet, straight
|
||||
// reverse is the least surprising escape because virtual walls are meant
|
||||
// to block forward travel into a restricted area.
|
||||
return { left: -safeSpeed, right: -safeSpeed };
|
||||
}
|
||||
|
||||
let leftSign = Number(direction.left) || 0;
|
||||
let rightSign = Number(direction.right) || 0;
|
||||
if (leftSign === 0 && rightSign === 0) {
|
||||
leftSign = 1;
|
||||
rightSign = 1;
|
||||
} else if (leftSign === 0) {
|
||||
leftSign = rightSign;
|
||||
} else if (rightSign === 0) {
|
||||
rightSign = leftSign;
|
||||
}
|
||||
return {
|
||||
left: -leftSign * safeSpeed,
|
||||
right: -rightSign * safeSpeed,
|
||||
};
|
||||
}
|
||||
|
||||
function evaluatePrivateSafety(record, sensors) {
|
||||
@@ -118,22 +178,49 @@ function createSensorPipeline(deps) {
|
||||
const cliff = Boolean(
|
||||
sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight,
|
||||
);
|
||||
const virtualWall = Boolean(sensors?.virtualWall);
|
||||
const currentOver = overcurrent;
|
||||
const currentBump = bump;
|
||||
const currentCliff = cliff;
|
||||
const currentVirtualWall = virtualWall;
|
||||
if (!shouldApplyPrivateSensorSafety(record)) {
|
||||
state.blockedUntil = 0;
|
||||
state.lastOvercurrent = currentOver;
|
||||
state.lastBump = currentBump;
|
||||
state.lastCliff = currentCliff;
|
||||
state.lastVirtualWall = currentVirtualWall;
|
||||
return;
|
||||
}
|
||||
const safety = getPrivateSafety(record);
|
||||
const now = Date.now();
|
||||
if (safety.virtualWallEnabled && currentVirtualWall) {
|
||||
const wasAlreadyBlocked = now < Number(state.blockedUntil || 0);
|
||||
/*
|
||||
Virtual walls are different from bump/cliff edges: staying in the beam
|
||||
is itself unsafe, so the guard must keep asserting the stop/backoff
|
||||
command even while the normal private-safety cooldown is active. The
|
||||
repeated command path is intentional; only duplicate alerts/events are
|
||||
suppressed during the existing blocked window so a held wall signal does
|
||||
not flood chat/log surfaces at sensor-frame rate.
|
||||
*/
|
||||
triggerSafetyAction(record, 'virtualWall', {
|
||||
cooldownMs: safety.triggerCooldownMs,
|
||||
backoffMs: safety.virtualWallBackoffMs,
|
||||
backoffSpeed: safety.virtualWallBackoffSpeed,
|
||||
backoffDrive: getOppositePrivateDrive(record, safety.virtualWallBackoffSpeed),
|
||||
notify: !wasAlreadyBlocked,
|
||||
});
|
||||
state.lastOvercurrent = currentOver;
|
||||
state.lastBump = currentBump;
|
||||
state.lastCliff = currentCliff;
|
||||
state.lastVirtualWall = currentVirtualWall;
|
||||
return;
|
||||
}
|
||||
if (now < Number(state.blockedUntil || 0)) {
|
||||
state.lastOvercurrent = currentOver;
|
||||
state.lastBump = currentBump;
|
||||
state.lastCliff = currentCliff;
|
||||
state.lastVirtualWall = currentVirtualWall;
|
||||
return;
|
||||
}
|
||||
let triggered = false;
|
||||
@@ -163,6 +250,7 @@ function createSensorPipeline(deps) {
|
||||
state.lastOvercurrent = currentOver;
|
||||
state.lastBump = currentBump;
|
||||
state.lastCliff = currentCliff;
|
||||
state.lastVirtualWall = currentVirtualWall;
|
||||
}
|
||||
|
||||
function applyPrivateDriveSafety(roverId, socket, driveDirect = null) {
|
||||
@@ -170,11 +258,20 @@ function createSensorPipeline(deps) {
|
||||
if (!record || !driveDirect || typeof driveDirect !== 'object') return driveDirect;
|
||||
if (!shouldApplyPrivateSafety(record, socket)) return driveDirect;
|
||||
const safety = getPrivateSafety(record);
|
||||
if (!safety.speedLimitEnabled) return driveDirect;
|
||||
if (!safety.speedLimitEnabled) {
|
||||
rememberPrivateDriveDirection(roverId, driveDirect);
|
||||
return driveDirect;
|
||||
}
|
||||
const limit = clampInt(safety.speedLimitMaxWheelSpeed, 1, 500, DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed);
|
||||
const left = clampInt(driveDirect.left, -500, 500, 0);
|
||||
const right = clampInt(driveDirect.right, -500, 500, 0);
|
||||
return { ...driveDirect, left: Math.max(-limit, Math.min(limit, left)), right: Math.max(-limit, Math.min(limit, right)) };
|
||||
const safeDrive = {
|
||||
...driveDirect,
|
||||
left: Math.max(-limit, Math.min(limit, left)),
|
||||
right: Math.max(-limit, Math.min(limit, right)),
|
||||
};
|
||||
rememberPrivateDriveDirection(roverId, safeDrive);
|
||||
return safeDrive;
|
||||
}
|
||||
|
||||
function handlePrivateButtonHold(record, sensors) {
|
||||
@@ -323,6 +420,68 @@ function createSensorPipeline(deps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getDockProtectionStrikeState(socketId) {
|
||||
/*
|
||||
The strike record is keyed by driver socket because the moderation action
|
||||
removes a person from control, not a rover from service. The last rover is
|
||||
still tracked so "three in a row" means repeated bump-off-dock incidents
|
||||
by the same browser session without a different rover resetting context.
|
||||
*/
|
||||
const key = String(socketId || '').trim();
|
||||
if (!key) return null;
|
||||
if (!dockProtectionStrikeStates.has(key)) {
|
||||
dockProtectionStrikeStates.set(key, {
|
||||
count: 0,
|
||||
lastRoverId: null,
|
||||
updatedAt: 0,
|
||||
});
|
||||
}
|
||||
return dockProtectionStrikeStates.get(key);
|
||||
}
|
||||
|
||||
function recordDockProtectionStrike(suspect) {
|
||||
const socketId = String(suspect?.socketId || '').trim();
|
||||
const roverId = String(suspect?.roverId || '').trim();
|
||||
const state = getDockProtectionStrikeState(socketId);
|
||||
if (!state || !roverId) return 0;
|
||||
const now = Date.now();
|
||||
const previousIsFresh = state.updatedAt && now - state.updatedAt <= DOCK_PROTECTION_STRIKE_RESET_MS;
|
||||
/*
|
||||
Consecutive protection hits should punish repeated behavior, not stale
|
||||
memory from some unrelated rover interaction. Switching suspect rover
|
||||
resets the count because the dock-protection heuristic has a different
|
||||
physical context and should earn its own three-strike sequence.
|
||||
*/
|
||||
state.count = previousIsFresh && state.lastRoverId === roverId ? state.count + 1 : 1;
|
||||
state.lastRoverId = roverId;
|
||||
state.updatedAt = now;
|
||||
return state.count;
|
||||
}
|
||||
|
||||
function clearDockProtectionStrikes(socketId) {
|
||||
const key = String(socketId || '').trim();
|
||||
if (key) dockProtectionStrikeStates.delete(key);
|
||||
}
|
||||
|
||||
function removeDriverForDockProtection(suspect, strikes) {
|
||||
const socketId = String(suspect?.socketId || '').trim();
|
||||
const roverId = String(suspect?.roverId || '').trim();
|
||||
if (!socketId || !roverId) return false;
|
||||
const assignmentService = require('../assignmentService');
|
||||
/*
|
||||
Release through assignmentService so rover membership, turn queues, and
|
||||
the browser-facing removal notice all move together. Directly editing
|
||||
roverManager sets here would skip queue cleanup and produce stale UI.
|
||||
*/
|
||||
assignmentService.forceReleaseWithNotice(roverId, socketId, {
|
||||
title: 'Removed for dock protection',
|
||||
message: `You were removed from ${roverId} after triggering bump-off-dock protection ${strikes} times in a row.`,
|
||||
reasonCode: 'dock-protection',
|
||||
});
|
||||
clearDockProtectionStrikes(socketId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleIdleUndock(undockedRecord) {
|
||||
if (!undockedRecord || undockedRecord.drivers.size > 0) return;
|
||||
const now = Date.now();
|
||||
@@ -342,10 +501,11 @@ function createSensorPipeline(deps) {
|
||||
const suspectRecord = rovers.get(suspect.roverId);
|
||||
if (!suspectRecord) return;
|
||||
const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||
const strikes = recordDockProtectionStrike(suspect);
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Dock protection',
|
||||
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`,
|
||||
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}${strikes ? ` (${strikes}/${DOCK_PROTECTION_MAX_STRIKES})` : ''}.`,
|
||||
});
|
||||
try {
|
||||
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||
@@ -356,17 +516,40 @@ function createSensorPipeline(deps) {
|
||||
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
|
||||
if (bumpRecent) nudgeRover(suspect.roverId, 'backward');
|
||||
else nudgeRover(suspect.roverId, 'forward');
|
||||
if (strikes >= DOCK_PROTECTION_MAX_STRIKES && removeDriverForDockProtection(suspect, strikes)) {
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Driver removed',
|
||||
message: `${suspect.socketId} removed from ${suspect.roverId} after ${strikes} dock-protection triggers.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSensorFrame(roverId, frame) {
|
||||
const record = rovers.get(roverId);
|
||||
if (!record) return;
|
||||
record.lastSeen = Date.now();
|
||||
const decoded = parseSensorFrame(frame.data);
|
||||
let decoded = parseSensorFrame(frame.data);
|
||||
record.lastSensor = { raw: frame, decoded };
|
||||
record.batteryState = computeBatteryState(record, decoded);
|
||||
let odometer = null;
|
||||
if (typeof processOdometerFrame === 'function') {
|
||||
processOdometerFrame(roverId, decoded);
|
||||
odometer = processOdometerFrame(roverId, decoded);
|
||||
}
|
||||
if (decoded && odometer?.wheelSpeedsMmPerSecond) {
|
||||
/*
|
||||
Wheel speed is not a native packet in the Roomba sensor stream; it is a
|
||||
synthesized sensor produced from the same encoder deltas that drive the
|
||||
persistent odometer. Merging it into the decoded sensor object keeps the
|
||||
public socket contract simple: clients still read one JSON `sensors`
|
||||
payload per frame, with calculated values clearly grouped under their
|
||||
own wheel-speed key.
|
||||
*/
|
||||
decoded = {
|
||||
...decoded,
|
||||
wheelSpeedsMmPerSecond: odometer.wheelSpeedsMmPerSecond,
|
||||
};
|
||||
record.lastSensor = { raw: frame, decoded };
|
||||
}
|
||||
updateMovement(record, decoded);
|
||||
const hasDockInfo = decoded?.chargingSources != null;
|
||||
|
||||
@@ -9,6 +9,7 @@ const spectatorSockets = new Set();
|
||||
const managerEvents = new EventEmitter();
|
||||
const backoffTimers = new Map();
|
||||
const dockGuardStates = new Map();
|
||||
const dockProtectionStrikeStates = new Map();
|
||||
const privateButtonStates = new Map();
|
||||
const privateNoUsersSince = new Map();
|
||||
const privateSafetyTimers = new Map();
|
||||
@@ -21,6 +22,7 @@ module.exports = {
|
||||
managerEvents,
|
||||
backoffTimers,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
privateButtonStates,
|
||||
privateNoUsersSince,
|
||||
privateSafetyTimers,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
// Purpose: Defines timing and static social/config constants used by session synchronization behavior.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating constants from orchestration logic.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getConfiguredSocials } = require('../../helpers/features');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordInvite = config.discord?.invite || null;
|
||||
const kofiLink = config.kofi?.link || null;
|
||||
const serverTimezone = config.timezone || null;
|
||||
const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
|
||||
const configuredSocials = getConfiguredSocials(config);
|
||||
|
||||
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
|
||||
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
|
||||
|
||||
@@ -10,6 +10,12 @@ const { managerEvents } = roverManager;
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const {
|
||||
getPublicState: getPtzCameraState,
|
||||
getChatTargetForSocket: getPtzChatTargetForSocket,
|
||||
PTZ_CAMERA_ID,
|
||||
ptzCameraEvents,
|
||||
} = require('../ptzCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||
@@ -32,9 +38,11 @@ const { getGlobalObjective } = require('../globalObjectiveService');
|
||||
const { getAdminReason } = require('../adminReasonService');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const { getFeatureFlags } = require('../../helpers/features');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getButtonBoxState } = require('../buttonBoxService');
|
||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||
const {
|
||||
discordInvite,
|
||||
kofiLink,
|
||||
@@ -59,41 +67,55 @@ function buildUserEntry(socket) {
|
||||
const role = getRole(socket);
|
||||
const assignment = assignmentService.describeAssignment(socket.id);
|
||||
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
|
||||
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
|
||||
return {
|
||||
socketId: socket.id,
|
||||
userId: socket?.data?.userId || null,
|
||||
nickname: getNickname(socket) || null,
|
||||
role,
|
||||
roverId: primaryRover || assignment?.roverId || null,
|
||||
/*
|
||||
PTZ is not inserted into the physical rover roster, but for chat and user
|
||||
presence it should read like the user moved to a rover-like target. Prefer
|
||||
the PTZ chat target while the socket is queued or operating so presence,
|
||||
queue lookup, and chat identity all agree.
|
||||
*/
|
||||
roverId: ptzChatTarget?.roverId || primaryRover || assignment?.roverId || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(socket) {
|
||||
const overseerVote = getOverseerVoteStatus();
|
||||
const features = getFeatureFlags();
|
||||
const users = Array.from(io.sockets.sockets.values())
|
||||
.map((sock) => buildUserEntry(sock))
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
roverId: filterVisibleRoverId(socket, entry.roverId),
|
||||
/*
|
||||
PTZ is intentionally not a roverManager record, so the normal physical
|
||||
rover visibility filter would erase the user's PTZ chat target. Preserve
|
||||
it here because getPtzChatTargetForSocket already applied the PTZ access
|
||||
and queue/operator rules before buildUserEntry returned it.
|
||||
*/
|
||||
roverId: entry.roverId === PTZ_CAMERA_ID ? entry.roverId : filterVisibleRoverId(socket, entry.roverId),
|
||||
}));
|
||||
const roster = roverManager.getRosterForSocket(socket);
|
||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
|
||||
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
|
||||
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
|
||||
const socials =
|
||||
configuredSocials?.length
|
||||
? configuredSocials
|
||||
: [
|
||||
...(discordInvite ? [{ id: 'discord', label: 'Discord', url: discordInvite }] : []),
|
||||
...(kofiLink ? [{ id: 'kofi', label: 'Ko-fi', url: kofiLink }] : []),
|
||||
];
|
||||
const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
|
||||
return {
|
||||
socketId: socket?.id || null,
|
||||
role: getRole(socket),
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
/*
|
||||
Features is the single UI contract for optional server capabilities. A
|
||||
disabled feature should be absent from navigation/layout decisions even
|
||||
though the service module may still be loaded on the Node side.
|
||||
*/
|
||||
features,
|
||||
roster,
|
||||
odometers: roverManager.getOdometersForSocket(socket),
|
||||
assignment: {
|
||||
@@ -104,6 +126,7 @@ function buildSession(socket) {
|
||||
activeDrivers,
|
||||
turnQueues,
|
||||
roomCameras: getRoomCameras(),
|
||||
ptzCamera: getPtzCameraState(socket),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
neato: getNeatoState(),
|
||||
lift: getLiftState(),
|
||||
@@ -130,6 +153,12 @@ function buildSession(socket) {
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
buttonBox: getButtonBoxState(),
|
||||
/*
|
||||
Inter-instance state is a read-only directory snapshot. It is included in
|
||||
session sync because the UI already treats session payloads as the source
|
||||
of truth for rovers, queues, and public feature availability.
|
||||
*/
|
||||
interInstances: getInterInstanceState(),
|
||||
overseerVote: {
|
||||
...overseerVote,
|
||||
preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true,
|
||||
@@ -272,6 +301,11 @@ roomCameraEvents.on('update', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
ptzCameraEvents.on('change', () => {
|
||||
logger.info('PTZ camera state change; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
homeAssistantEvents.on('update', () => {
|
||||
logger.info('Home Assistant state change; syncing all clients');
|
||||
syncAll();
|
||||
@@ -345,6 +379,10 @@ audioLevelsEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
interInstanceEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
// sync all sockets 20 seconds
|
||||
// setInterval(() => {
|
||||
// logger.info('Periodic session sync for all clients');
|
||||
|
||||
@@ -11,9 +11,18 @@ function stopRover(roverId) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeDriverCompletely(roverId, socketId) {
|
||||
function removeDriverCompletely(roverId, socketId, notice = null) {
|
||||
try {
|
||||
const assignmentService = require('../assignmentService');
|
||||
/*
|
||||
Turn-service removals should explain themselves to the affected browser
|
||||
when a caller provides notice metadata. Plain forceRelease remains the
|
||||
fallback for old internal cleanup paths that only need to mutate state.
|
||||
*/
|
||||
if (notice && typeof assignmentService.forceReleaseWithNotice === 'function') {
|
||||
assignmentService.forceReleaseWithNotice(roverId, socketId, notice);
|
||||
return;
|
||||
}
|
||||
assignmentService.forceRelease(roverId, socketId);
|
||||
} catch (err) {
|
||||
// best effort; log elsewhere if needed
|
||||
|
||||
@@ -290,12 +290,17 @@ function handleIdleTimeout(roverId, expectedDriver) {
|
||||
const skips = incrementSkip(roverId, expectedDriver);
|
||||
stopRover(roverId);
|
||||
if (skips >= MAX_IDLE_SKIPS) {
|
||||
const removalMessage = `You were removed from ${roverId} after ${skips} idle skips because no driving input was detected during your turns.`;
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Driver removed',
|
||||
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
|
||||
});
|
||||
removeDriverCompletely(roverId, expectedDriver);
|
||||
removeDriverCompletely(roverId, expectedDriver, {
|
||||
title: 'Removed for inactivity',
|
||||
message: removalMessage,
|
||||
reasonCode: 'idle-removal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendAlert({
|
||||
|
||||
@@ -10,6 +10,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const turnService = require('../turnService');
|
||||
const roverManager = require('../roverManager');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const { getRequestIp, getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const { logAdminEvent } = require('../adminLogService');
|
||||
|
||||
@@ -26,6 +27,7 @@ const { canAccessStream } = createVideoAuthPolicy({
|
||||
isVerified,
|
||||
turnService,
|
||||
roverManager,
|
||||
ptzCameraService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ function createVideoAuthPolicy(deps) {
|
||||
isVerified,
|
||||
turnService,
|
||||
roverManager,
|
||||
ptzCameraService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
} = deps;
|
||||
@@ -40,6 +41,10 @@ function createVideoAuthPolicy(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (streamInfo.type === 'ptz') {
|
||||
return ptzCameraService.canRequestLiveVideo(socket);
|
||||
}
|
||||
|
||||
if (sourceType === 'roverMic' && action === 'publish') {
|
||||
const roverId = streamInfo.baseId || streamInfo.id;
|
||||
if (!isVerified(socket)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ const { loadConfig } = require('../../helpers/configLoader');
|
||||
|
||||
const config = loadConfig();
|
||||
const mediaConfig = config.media || {};
|
||||
const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
|
||||
function getPathPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
@@ -37,6 +38,14 @@ function extractStreamInfo(path) {
|
||||
const remaining = segments.slice(start, end);
|
||||
if (remaining.length === 1) {
|
||||
const rawId = remaining[0] || '';
|
||||
/*
|
||||
PTZ is intentionally published as a flat MediaMTX path so WHEP requests
|
||||
line up with the real stream name. Treat that one reserved path as PTZ
|
||||
before falling back to the normal one-segment rover parsing rules.
|
||||
*/
|
||||
if (rawId === PTZ_STREAM_PATH) {
|
||||
return { type: 'ptz', id: rawId };
|
||||
}
|
||||
if (rawId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: rawId, baseId: rawId.slice(0, -4) };
|
||||
}
|
||||
@@ -48,6 +57,10 @@ function extractStreamInfo(path) {
|
||||
return { type: 'room', id: remaining[1] || '' };
|
||||
}
|
||||
|
||||
if (remaining.length === 2 && remaining[0] === 'ptz') {
|
||||
return { type: 'ptz', id: remaining[1] || '' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -77,6 +90,10 @@ function extractStreamInfoFromBody(body = {}) {
|
||||
extractSrtStreamId(body.query);
|
||||
if (!srtId) return null;
|
||||
|
||||
if (srtId === PTZ_STREAM_PATH) {
|
||||
return { type: 'ptz', id: srtId };
|
||||
}
|
||||
|
||||
if (srtId.endsWith('-fwd')) {
|
||||
return { type: 'rover', id: srtId, baseId: srtId.slice(0, -4) };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const roverManager = require('../roverManager');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
|
||||
@@ -34,6 +35,15 @@ function buildWhepUrlForSource(source) {
|
||||
const segments = [];
|
||||
if (source.type === 'room') {
|
||||
segments.push('room', encodeURIComponent(source.id));
|
||||
} else if (source.type === 'ptz') {
|
||||
/*
|
||||
MediaMTX exposes WHEP by the exact path name that is being published.
|
||||
The PTZ ffmpeg publisher registers the single camera as "ptz-camera",
|
||||
so the browser must request "/video/ptz-camera/whep" instead of a
|
||||
namespace-like "/video/ptz/ptz-camera/whep" path that MediaMTX has never
|
||||
seen and correctly returns as 404.
|
||||
*/
|
||||
segments.push(encodeURIComponent(source.id));
|
||||
} else {
|
||||
segments.push(encodeURIComponent(source.id));
|
||||
}
|
||||
@@ -81,6 +91,9 @@ function normalizeRequest(payload = {}) {
|
||||
if (payload.roomCameraId) {
|
||||
return { type: 'room', id: String(payload.roomCameraId) };
|
||||
}
|
||||
if (payload.ptzCameraId || payload.type === 'ptz') {
|
||||
return { type: 'ptz', id: String(payload.ptzCameraId || payload.id || ptzCameraService.PTZ_CAMERA_ID) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,6 +122,13 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
} else if (target.type === 'room') {
|
||||
throw new Error('Room cameras now use the snapshot feed');
|
||||
} else if (target.type === 'ptz') {
|
||||
if (target.id !== ptzCameraService.PTZ_CAMERA_ID) {
|
||||
throw new Error('Unknown PTZ camera');
|
||||
}
|
||||
if (!ptzCameraService.canRequestLiveVideo(socket)) {
|
||||
throw new Error('Not authorized for PTZ video');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unsupported video source');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
1. improve spectator page, options on what to see and what not to see
|
||||
2. pagewide system for "why was i removed from a rover", instead of the link to spectator page thing
|
||||
3. admin command for kicking people off of rovers. just a kick, nothing persistent
|
||||
4. kick people off rover after 3 consecurtive bump-off attempts
|
||||
5. add rs admin command for controlling light lock
|
||||
6. make signaling more clear for idle skips and idle skip kicks
|
||||
7. fix this:
|
||||
2. assign rovers based on battery percentage, give people highest one
|
||||
3. add config to disable client snapshot forcing, disable bandwidth saving
|
||||
4. add admin ui for VIP and private requests instead of only through discord
|
||||
5. pull page / tab title from session, use the name from profile of interinstance if enabled, if not just default to old one
|
||||
6. make overcurrent limiter speed sensitive, slower fill at lower speeds
|
||||
7. add feature chat commands, like /neato start, /lift up, etc
|
||||
8. add more background gap themes
|
||||
9. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
+12
-3
@@ -35,8 +35,10 @@ import SettingsPanel from './components/SettingsPanel/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
|
||||
import useDefaultNickname from './hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from './hooks/useUserIdentitySync.js';
|
||||
import useIncomingInterInstanceTransfer from './hooks/useIncomingInterInstanceTransfer.js';
|
||||
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
|
||||
import PtzQueueCard from './components/PtzCamera/index.jsx';
|
||||
import VipPanel from './components/VipPanel/index.jsx';
|
||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
|
||||
@@ -205,7 +207,7 @@ function MobileFeatureTabs({
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="vip" keepMounted>
|
||||
<VipPanel isActive={activeTab === 'vip'} />
|
||||
<VipPanel isActive={activeTab === 'vip'} layout={layout} />
|
||||
</TabPanel>
|
||||
<TabPanel id="roomcontrols">
|
||||
<div className={themeStackClass}>
|
||||
@@ -250,7 +252,10 @@ function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = fa
|
||||
</section>
|
||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||
<RoverQueuesPanel />
|
||||
<div className="space-y-0.5">
|
||||
<RoverQueuesPanel />
|
||||
<PtzQueueCard layout="mobile-portrait" />
|
||||
</div>
|
||||
</div>
|
||||
{/* <ControlSummary /> */}
|
||||
<MobileFeatureTabs
|
||||
@@ -278,7 +283,10 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
|
||||
<DriverVideo layoutFormat="mobile-landscape" />
|
||||
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||
<RoverQueuesPanel />
|
||||
<div className="space-y-0.5">
|
||||
<RoverQueuesPanel />
|
||||
<PtzQueueCard layout="mobile-landscape" />
|
||||
</div>
|
||||
</div>
|
||||
{/* <TelemetryPanel /> */}
|
||||
</div>
|
||||
@@ -309,6 +317,7 @@ function App() {
|
||||
|
||||
function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
useDefaultNickname();
|
||||
useIncomingInterInstanceTransfer();
|
||||
useUserIdentitySync({ identitySurface: 'driver' });
|
||||
useTelemetryVisualPolicy({ mobile: !isDesktop });
|
||||
const {
|
||||
|
||||
@@ -18,6 +18,47 @@ const MODES = [
|
||||
{ key: 'lockdown', label: 'Lockdown' },
|
||||
];
|
||||
|
||||
function buildPrivateSafetyDraft(rover) {
|
||||
const safety = rover?.private?.safety || {};
|
||||
return {
|
||||
speedLimitEnabled: Boolean(safety.speedLimitEnabled),
|
||||
speedLimitMaxWheelSpeed: Number.isFinite(safety.speedLimitMaxWheelSpeed)
|
||||
? safety.speedLimitMaxWheelSpeed
|
||||
: 250,
|
||||
hardOvercurrentEnabled: Boolean(safety.hardOvercurrentEnabled),
|
||||
overcurrentStopMs: Number.isFinite(safety.overcurrentStopMs)
|
||||
? safety.overcurrentStopMs
|
||||
: 300,
|
||||
hardBumpEnabled: Boolean(safety.hardBumpEnabled),
|
||||
bumpBackoffSpeed: Number.isFinite(safety.bumpBackoffSpeed)
|
||||
? safety.bumpBackoffSpeed
|
||||
: 250,
|
||||
bumpBackoffMs: Number.isFinite(safety.bumpBackoffMs)
|
||||
? safety.bumpBackoffMs
|
||||
: 350,
|
||||
cliffEnabled: Boolean(safety.cliffEnabled),
|
||||
cliffBackoffSpeed: Number.isFinite(safety.cliffBackoffSpeed)
|
||||
? safety.cliffBackoffSpeed
|
||||
: 250,
|
||||
cliffBackoffMs: Number.isFinite(safety.cliffBackoffMs)
|
||||
? safety.cliffBackoffMs
|
||||
: 500,
|
||||
// Virtual wall is default-on for private rovers. Older rover hellos will
|
||||
// not include this field, so the admin form mirrors the server default
|
||||
// instead of rendering an unchecked box that would accidentally disable it.
|
||||
virtualWallEnabled: safety.virtualWallEnabled == null ? true : Boolean(safety.virtualWallEnabled),
|
||||
virtualWallBackoffSpeed: Number.isFinite(safety.virtualWallBackoffSpeed)
|
||||
? safety.virtualWallBackoffSpeed
|
||||
: 250,
|
||||
virtualWallBackoffMs: Number.isFinite(safety.virtualWallBackoffMs)
|
||||
? safety.virtualWallBackoffMs
|
||||
: 500,
|
||||
triggerCooldownMs: Number.isFinite(safety.triggerCooldownMs)
|
||||
? safety.triggerCooldownMs
|
||||
: 800,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminPanelContent() {
|
||||
const {
|
||||
session,
|
||||
@@ -58,6 +99,7 @@ export default function AdminPanelContent() {
|
||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||
});
|
||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
||||
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
||||
|
||||
const isAdmin =
|
||||
session?.role === 'admin' ||
|
||||
@@ -257,39 +299,21 @@ export default function AdminPanelContent() {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const next = {};
|
||||
(roster || []).forEach((rover) => {
|
||||
if (!rover?.private?.enabled) return;
|
||||
next[rover.id] = {
|
||||
speedLimitEnabled: Boolean(rover?.private?.safety?.speedLimitEnabled),
|
||||
speedLimitMaxWheelSpeed: Number.isFinite(rover?.private?.safety?.speedLimitMaxWheelSpeed)
|
||||
? rover.private.safety.speedLimitMaxWheelSpeed
|
||||
: 250,
|
||||
hardOvercurrentEnabled: Boolean(rover?.private?.safety?.hardOvercurrentEnabled),
|
||||
overcurrentStopMs: Number.isFinite(rover?.private?.safety?.overcurrentStopMs)
|
||||
? rover.private.safety.overcurrentStopMs
|
||||
: 300,
|
||||
hardBumpEnabled: Boolean(rover?.private?.safety?.hardBumpEnabled),
|
||||
bumpBackoffSpeed: Number.isFinite(rover?.private?.safety?.bumpBackoffSpeed)
|
||||
? rover.private.safety.bumpBackoffSpeed
|
||||
: 250,
|
||||
bumpBackoffMs: Number.isFinite(rover?.private?.safety?.bumpBackoffMs)
|
||||
? rover.private.safety.bumpBackoffMs
|
||||
: 350,
|
||||
cliffEnabled: Boolean(rover?.private?.safety?.cliffEnabled),
|
||||
cliffBackoffSpeed: Number.isFinite(rover?.private?.safety?.cliffBackoffSpeed)
|
||||
? rover.private.safety.cliffBackoffSpeed
|
||||
: 250,
|
||||
cliffBackoffMs: Number.isFinite(rover?.private?.safety?.cliffBackoffMs)
|
||||
? rover.private.safety.cliffBackoffMs
|
||||
: 500,
|
||||
triggerCooldownMs: Number.isFinite(rover?.private?.safety?.triggerCooldownMs)
|
||||
? rover.private.safety.triggerCooldownMs
|
||||
: 800,
|
||||
};
|
||||
setPrivateSafetyDrafts((currentDrafts) => {
|
||||
const next = {};
|
||||
(roster || []).forEach((rover) => {
|
||||
if (!rover?.private?.enabled) return;
|
||||
// Session syncs can arrive while an admin is focused in one of these
|
||||
// inputs. Preserve that rover's local draft once touched; otherwise the
|
||||
// server roster remains the source of truth for newly connected rovers
|
||||
// and for rovers whose form is not being edited.
|
||||
next[rover.id] = privateSafetyDirty?.[rover.id]
|
||||
? currentDrafts?.[rover.id] || buildPrivateSafetyDraft(rover)
|
||||
: buildPrivateSafetyDraft(rover);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setPrivateSafetyDrafts(next);
|
||||
}, [roster]);
|
||||
}, [privateSafetyDirty, roster]);
|
||||
|
||||
const lockMap = useMemo(() => {
|
||||
const map = {};
|
||||
@@ -304,13 +328,25 @@ export default function AdminPanelContent() {
|
||||
...(current || {}),
|
||||
[roverId]: { ...(current?.[roverId] || {}), ...(patch || {}) },
|
||||
}));
|
||||
// Mark the whole rover draft dirty rather than individual fields. The form
|
||||
// is saved as one object, so protecting it as one unit avoids partial
|
||||
// server refreshes that mix old roster values with the user's unsaved edit.
|
||||
setPrivateSafetyDirty((current) => ({ ...(current || {}), [roverId]: true }));
|
||||
};
|
||||
|
||||
const handlePrivateSafetySave = async (roverId) => {
|
||||
try {
|
||||
const draft = privateSafetyDrafts?.[roverId];
|
||||
if (!draft) return;
|
||||
await setPrivateSafety(roverId, draft);
|
||||
const resp = await setPrivateSafety(roverId, draft);
|
||||
if (resp?.safety) {
|
||||
setPrivateSafetyDrafts((current) => ({ ...(current || {}), [roverId]: resp.safety }));
|
||||
}
|
||||
setPrivateSafetyDirty((current) => {
|
||||
const next = { ...(current || {}) };
|
||||
delete next[roverId];
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
@@ -536,6 +572,16 @@ export default function AdminPanelContent() {
|
||||
/>
|
||||
<span>Cliff safety</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(privateSafetyDrafts?.[rover.id]?.virtualWallEnabled)}
|
||||
disabled={!isLockdownAdmin}
|
||||
onChange={(event) =>
|
||||
updatePrivateSafetyDraft(rover.id, { virtualWallEnabled: Boolean(event.target.checked) })}
|
||||
/>
|
||||
<span>Virtual wall</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isLockdownAdmin}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function clampRgbChannel(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
@@ -247,6 +249,18 @@ function Leaderboard({ players, ownPlayer }) {
|
||||
);
|
||||
}
|
||||
export default function BarcodeGamesPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'barcodeGames'));
|
||||
|
||||
/*
|
||||
Barcode games depend on the optional scanner station. The panel owns the
|
||||
feature gate so disabled installs do not show empty game voting controls.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <BarcodeGamesPanelContent />;
|
||||
}
|
||||
|
||||
function BarcodeGamesPanelContent() {
|
||||
const { state, connectionState, voteForGame } = useBarcodeGameState();
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeSignatureRef = useRef('');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const FLASH_MS = 420;
|
||||
const REWARD_FLASH_MS = 1200;
|
||||
@@ -20,6 +21,18 @@ const BUTTON_TONES = {
|
||||
};
|
||||
|
||||
export default function ButtonBoxPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'buttonBox'));
|
||||
|
||||
/*
|
||||
The physical button box should vanish by owning its own feature gate. Routes
|
||||
can keep mounting this component without leaking empty reward panels.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <ButtonBoxPanelContent />;
|
||||
}
|
||||
|
||||
function ButtonBoxPanelContent() {
|
||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||
const socket = useSocket();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
|
||||
@@ -127,6 +127,7 @@ export function ChatIdentity({ message, toolsToggle = null }) {
|
||||
{message.roverId && (
|
||||
<RoverLabel
|
||||
roverId={message.roverId}
|
||||
name={message.roverName}
|
||||
color={message.roverColor}
|
||||
fallback={message.roverId}
|
||||
className="shrink-0 text-[0.7rem]"
|
||||
|
||||
@@ -49,17 +49,38 @@ function resolveTtsSettings(settings) {
|
||||
|
||||
function useChatComposerSessionState(allowSpectatorInput) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
|
||||
const chatTargetId = useMemo(() => {
|
||||
/*
|
||||
PTZ is intentionally not a roster entry, but session users expose the
|
||||
current chat target for each socket. Prefer the self user entry so the TTS
|
||||
control follows PTZ queue/operation state instead of only physical rover
|
||||
assignment state.
|
||||
*/
|
||||
const self = users.find((entry) => entry?.socketId === socketId);
|
||||
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
|
||||
return self?.roverId || currentRoverId || null;
|
||||
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
|
||||
|
||||
const rover = useMemo(
|
||||
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, roster],
|
||||
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
||||
[chatTargetId, roster],
|
||||
);
|
||||
const ptzTtsSupported = Boolean(
|
||||
ptz?.id &&
|
||||
String(chatTargetId) === String(ptz.id) &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.isOperator || ptz?.queuedPosition),
|
||||
);
|
||||
|
||||
return {
|
||||
canChat: role !== 'spectator' || allowSpectatorInput,
|
||||
ttsSupported: Boolean(rover?.audio?.ttsEnabled),
|
||||
ttsSupported: Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,6 +235,7 @@ function TtsControls({
|
||||
function ChatComposer({
|
||||
allowSpectatorInput = false,
|
||||
hideSpectatorNotice = false,
|
||||
inputTarget = 'panel',
|
||||
}) {
|
||||
const {
|
||||
sendMessage,
|
||||
@@ -310,7 +332,7 @@ function ChatComposer({
|
||||
setTypingActive(false);
|
||||
}
|
||||
}}
|
||||
ref={(el) => registerInputRef(el, { target: 'panel' })}
|
||||
ref={(el) => registerInputRef(el, { target: inputTarget })}
|
||||
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
||||
disabled={!canChat}
|
||||
/>
|
||||
@@ -346,6 +368,7 @@ export default function ChatPanel({
|
||||
allowSpectatorInput = false,
|
||||
title = 'Chat and speech',
|
||||
minimal = false,
|
||||
inputTarget = 'panel',
|
||||
}) {
|
||||
const effectiveHideInput = minimal || hideInput;
|
||||
const effectiveTitle = minimal ? '' : title;
|
||||
@@ -362,6 +385,7 @@ export default function ChatPanel({
|
||||
<MemoizedChatComposer
|
||||
allowSpectatorInput={allowSpectatorInput}
|
||||
hideSpectatorNotice={hideSpectatorNotice}
|
||||
inputTarget={inputTarget}
|
||||
/>
|
||||
)}
|
||||
</CardFrame>
|
||||
|
||||
@@ -11,6 +11,46 @@ import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
|
||||
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
|
||||
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
|
||||
|
||||
function EmptyDriverVideoNotice() {
|
||||
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
|
||||
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
|
||||
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
|
||||
/*
|
||||
Removal explanations should feel immediate and contextual. After a short
|
||||
window, falling back to the neutral no-rover state avoids showing an old
|
||||
moderation/safety message during unrelated later waiting periods.
|
||||
*/
|
||||
const showRemovalNotice = Boolean(removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS);
|
||||
const title = showRemovalNotice ? removalNotice.title || 'Removed from rover' : 'No rover assigned';
|
||||
const message = showRemovalNotice
|
||||
? removalNotice.message
|
||||
: 'You are not currently assigned to a rover.';
|
||||
|
||||
return (
|
||||
<CardFrame hideHeader className="shrink-0">
|
||||
<div className="panel-muted flex aspect-[4/3] items-center justify-center p-4 text-center">
|
||||
<div
|
||||
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
|
||||
showRemovalNotice
|
||||
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
|
||||
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
@@ -18,18 +58,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
|
||||
if (!roverId) {
|
||||
return (
|
||||
<CardFrame hideHeader className="shrink-0">
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||
<p>You are not assigned to a rover.</p>
|
||||
<p className="mt-0">
|
||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||
Click here to visit the spectator page.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
return <EmptyDriverVideoNotice />;
|
||||
}
|
||||
|
||||
const mobileHud = layoutFormat !== 'desktop';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const COLOR_SWATCHES = Object.freeze([
|
||||
{ id: 'white', label: 'White', hex: '#ffffff', action: 'white' },
|
||||
@@ -169,6 +170,19 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
|
||||
}
|
||||
|
||||
export default function HomeAssistantControls() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'homeAssistant'));
|
||||
|
||||
/*
|
||||
Feature existence is owned here, not by each layout that happens to mount
|
||||
room controls. Disabled integrations render nothing; enabled integrations
|
||||
can still show offline/configuration states inside the panel.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <HomeAssistantControlsContent />;
|
||||
}
|
||||
|
||||
function HomeAssistantControlsContent() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||
|
||||
@@ -24,8 +24,11 @@ function detectSafari() {
|
||||
|
||||
function HudChatInput({ compact = false }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const users = useSessionSelector((state) => state.session?.users || []);
|
||||
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
||||
const { value: ttsSettings } = useSettingsNamespace('tts', {
|
||||
// HUD chat shares the normal browser TTS defaults, but it has no visible
|
||||
@@ -41,11 +44,28 @@ function HudChatInput({ compact = false }) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const canChat = role !== 'spectator';
|
||||
const hideHudChat = role === 'spectator';
|
||||
const chatTargetId = useMemo(() => {
|
||||
/*
|
||||
HUD chat does not render the full composer controls, but it still sends
|
||||
TTS payloads when the active target supports speech. PTZ appears only in
|
||||
the session user target, not the physical rover roster, so use the same
|
||||
self-target resolution as the full chat panel.
|
||||
*/
|
||||
const self = users.find((entry) => entry?.socketId === socketId);
|
||||
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
|
||||
return self?.roverId || currentRoverId || null;
|
||||
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
|
||||
const rover = useMemo(
|
||||
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, roverRoster],
|
||||
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
||||
[chatTargetId, roverRoster],
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||
const ptzTtsSupported = Boolean(
|
||||
ptz?.id &&
|
||||
String(chatTargetId) === String(ptz.id) &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.isOperator || ptz?.queuedPosition),
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
||||
const ttsPayload = useMemo(() => {
|
||||
if (!ttsSupported) return null;
|
||||
const engine =
|
||||
|
||||
@@ -7,9 +7,10 @@ import SocialButton from '../../SocialButton/index.jsx';
|
||||
function TurnsOverlay({
|
||||
roverId = null,
|
||||
mobileHud = false,
|
||||
turnModel = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
@@ -29,21 +30,30 @@ function TurnsOverlay({
|
||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
|
||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||
const activeDriverId = turnModel
|
||||
? turnModel.activeId || null
|
||||
: effectiveRoverId
|
||||
? activeDrivers?.[effectiveRoverId] || null
|
||||
: null;
|
||||
const isActiveDriver = turnModel
|
||||
? Boolean(turnModel.isActive)
|
||||
: Boolean(socketId && activeDriverId === socketId);
|
||||
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
|
||||
const now = useSharedClock(1000, isTurnsMode);
|
||||
const nextDriverId = useMemo(() => {
|
||||
if (turnModel) return turnModel.nextId || null;
|
||||
const queue = turnInfo?.queue || [];
|
||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||
if (idx === -1) return queue[0] || null;
|
||||
return queue[(idx + 1) % queue.length] || null;
|
||||
}, [turnInfo]);
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
}, [turnInfo, turnModel]);
|
||||
const isNextDriver = turnModel
|
||||
? Boolean(turnModel.isNext)
|
||||
: Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
|
||||
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||
const totalRovers = roster.length;
|
||||
@@ -59,10 +69,14 @@ function TurnsOverlay({
|
||||
});
|
||||
return unique.size;
|
||||
}, [users]);
|
||||
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
||||
const shouldUsePreviewByLoad = turnModel
|
||||
? Boolean(turnModel.showPreviewReason)
|
||||
: isTurnsMode && totalDrivers > totalRovers;
|
||||
const isPreSwitchWindow =
|
||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||
const showNotTurnNotice = turnModel
|
||||
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
|
||||
: isTurnsMode && !isActiveDriver;
|
||||
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||
const turnSeconds =
|
||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||
@@ -82,19 +96,39 @@ function TurnsOverlay({
|
||||
const turnTimerFlashActive = noticeFlashActive;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
/*
|
||||
Rover turns and PTZ turns now arrive through the same render path. Use the
|
||||
normalized isTurnsMode flag here instead of checking the server's rover
|
||||
mode directly, otherwise PTZ can render the notice but never trigger the
|
||||
"your turn" cue when camera ownership changes.
|
||||
*/
|
||||
if (!isTurnsMode) {
|
||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||
return;
|
||||
/*
|
||||
React Compiler's lint rules disallow immediate state writes from effect
|
||||
bodies. Defer the visual reset one macrotask; the ref reset above stays
|
||||
synchronous so later turn comparisons do not see stale ownership.
|
||||
*/
|
||||
const resetTimer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
}, 0);
|
||||
return () => clearTimeout(resetTimer);
|
||||
}
|
||||
const lastTurn = lastTurnRef.current;
|
||||
const nextActiveDriverId = activeDriverId || null;
|
||||
if (!socketId || !effectiveRoverId) {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||
return;
|
||||
/*
|
||||
No socket/target means there is no turn identity to compare. Reset the
|
||||
comparison ref immediately, then defer the visual state reset for the
|
||||
same React Compiler reason documented in the mode-disabled branch.
|
||||
*/
|
||||
const resetTimer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
}, 0);
|
||||
return () => clearTimeout(resetTimer);
|
||||
}
|
||||
if (!lastTurn.initialized || lastTurn.roverId !== effectiveRoverId) {
|
||||
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
|
||||
@@ -104,37 +138,76 @@ function TurnsOverlay({
|
||||
Boolean(lastTurn.activeDriverId) &&
|
||||
lastTurn.activeDriverId !== socketId &&
|
||||
nextActiveDriverId === socketId;
|
||||
let cueTimer = 0;
|
||||
if (becameActive) {
|
||||
setShowTurnCue(true);
|
||||
setTurnCueStartAt(Date.now());
|
||||
const cueStartAt = Date.now();
|
||||
/*
|
||||
The active-turn cue is still caused by this ownership transition, but
|
||||
React Compiler wants visual state writes scheduled from an async edge.
|
||||
Capture the timestamp now so the cue dismissal logic compares against
|
||||
the actual transition time, not the later timer callback time.
|
||||
*/
|
||||
cueTimer = setTimeout(() => {
|
||||
setShowTurnCue(true);
|
||||
setTurnCueStartAt(cueStartAt);
|
||||
}, 0);
|
||||
} else if (nextActiveDriverId !== socketId && showTurnCue) {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
cueTimer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
}, 0);
|
||||
}
|
||||
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
|
||||
}, [activeDriverId, mode, effectiveRoverId, socketId, showTurnCue]);
|
||||
return () => {
|
||||
if (cueTimer) clearTimeout(cueTimer);
|
||||
};
|
||||
}, [activeDriverId, effectiveRoverId, isTurnsMode, socketId, showTurnCue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTurnCue || !turnCueStartAt) return;
|
||||
if (lastControlIntentAt > turnCueStartAt) {
|
||||
setShowTurnCue(false);
|
||||
/*
|
||||
Hide the large "your turn" cue after the first control intent, but
|
||||
schedule the state write outside the effect body so this shared overlay
|
||||
remains compatible with the repo's React Compiler lint settings.
|
||||
*/
|
||||
const timer = setTimeout(() => setShowTurnCue(false), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [lastControlIntentAt, showTurnCue, turnCueStartAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastIntent = Number(lastIntentRef.current) || 0;
|
||||
const nextIntent = Number(lastControlIntentAt) || 0;
|
||||
if (nextIntent > lastIntent && showNotTurnNotice) {
|
||||
setNotTurnFlashAt(Date.now());
|
||||
const flashAt = Date.now();
|
||||
/*
|
||||
The "not your turn" flash is a direct response to a recorded control
|
||||
intent. Deferring only the state write preserves the timestamp while
|
||||
satisfying the same effect-state lint rule as the turn cue reset.
|
||||
*/
|
||||
const timer = setTimeout(() => setNotTurnFlashAt(flashAt), 0);
|
||||
lastIntentRef.current = nextIntent;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
lastIntentRef.current = nextIntent;
|
||||
return undefined;
|
||||
}, [lastControlIntentAt, showNotTurnNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
|
||||
setNoticeFlashActive(true);
|
||||
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
|
||||
return () => clearTimeout(timer);
|
||||
/*
|
||||
The flash has two timed edges: activate on the next task, then clear after
|
||||
the visible pulse duration. Owning both timers here keeps cleanup local
|
||||
when the user becomes operator or leaves the PTZ/rover turn surface.
|
||||
*/
|
||||
const startTimer = setTimeout(() => setNoticeFlashActive(true), 0);
|
||||
const endTimer = setTimeout(() => setNoticeFlashActive(false), 650);
|
||||
return () => {
|
||||
clearTimeout(startTimer);
|
||||
clearTimeout(endTimer);
|
||||
};
|
||||
}, [showNotTurnNotice, notTurnFlashAt]);
|
||||
|
||||
return (
|
||||
@@ -195,9 +268,12 @@ function TurnsOverlay({
|
||||
Video switched to preview mode to save bandwidth.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-auto mt-0.5">
|
||||
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/>
|
||||
</div>
|
||||
<SocialButton
|
||||
id="discord"
|
||||
label="Join our Discord while you wait!"
|
||||
layout="inline"
|
||||
className="pointer-events-auto mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Inter Instance Panel
|
||||
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
||||
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function useRemoteInstances() {
|
||||
return useSessionSelector((state) => state.session?.interInstances?.instances ?? []);
|
||||
}
|
||||
|
||||
function useInterInstanceEnabled() {
|
||||
return useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
}
|
||||
|
||||
function featureEntries(features = {}) {
|
||||
return Object.entries(features || {})
|
||||
.filter(([, enabled]) => Boolean(enabled))
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
function getRemoteAvailability(remote) {
|
||||
const mode = remote?.instance?.mode || 'unknown';
|
||||
if (!remote?.online) return { blocked: true, label: 'Offline', overlay: 'This server is offline', tone: 'red' };
|
||||
if (mode === 'lockdown') return { blocked: true, label: 'Lockdown', overlay: 'This server is currently in lockdown mode', tone: 'red' };
|
||||
if (mode === 'admin') return { blocked: true, label: 'Admin only', overlay: 'This server is currently admin locked', tone: 'red' };
|
||||
if (mode === 'turns') return { blocked: false, label: 'Turns mode (open)', tone: 'emerald' };
|
||||
if (mode === 'open') return { blocked: false, label: 'Open mode', tone: 'emerald' };
|
||||
return { blocked: false, label: mode, tone: 'slate' };
|
||||
}
|
||||
|
||||
function statusClass(tone) {
|
||||
switch (tone) {
|
||||
case 'emerald':
|
||||
return 'bg-emerald-700/70 text-emerald-50 ring-1 ring-emerald-300/50';
|
||||
case 'sky':
|
||||
return 'bg-sky-700/70 text-sky-50 ring-1 ring-sky-300/50';
|
||||
case 'amber':
|
||||
return 'bg-amber-600/80 text-amber-50 ring-1 ring-amber-200/60';
|
||||
case 'red':
|
||||
return 'bg-red-700/80 text-red-50 ring-1 ring-red-300/60';
|
||||
default:
|
||||
return 'bg-slate-700/80 text-slate-50 ring-1 ring-slate-400/40';
|
||||
}
|
||||
}
|
||||
|
||||
function InstanceStatus({ remote }) {
|
||||
const availability = getRemoteAvailability(remote);
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-0.5 text-[0.7rem]">
|
||||
<span className={classNames('rounded px-1.5 py-0.5 text-xs font-semibold', statusClass(availability.tone))}>
|
||||
{availability.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceLatency({ remote }) {
|
||||
if (remote?.latencyMs == null) return null;
|
||||
return <span className="text-[0.7rem] font-normal text-slate-500">{remote.latencyMs}ms</span>;
|
||||
}
|
||||
|
||||
function InstancePanel({ remote, children = null }) {
|
||||
const instance = remote?.instance || {};
|
||||
const features = featureEntries(instance.features);
|
||||
const color = instance.color || '#64748b';
|
||||
const online = Boolean(remote?.online);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||
const includeSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||
return (
|
||||
<CardFrame
|
||||
title={instance.name || remote.url || 'External server'}
|
||||
meta={<InstanceLatency remote={remote} />}
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
>
|
||||
<div className="h-1 w-full" style={{ backgroundColor: color }} title={color} />
|
||||
|
||||
<div className="space-y-1">
|
||||
{/*
|
||||
The status is the main operational signal for a remote instance, so it
|
||||
stays beside the human-facing description where users naturally scan
|
||||
the server summary. Latency is intentionally moved to the title bar as
|
||||
quiet metadata because it is useful detail, not the primary decision.
|
||||
*/}
|
||||
<div className="flex flex-wrap items-center justify-center gap-1 text-center">
|
||||
{online && instance.description ? <p className="min-w-0 text-slate-200">{instance.description}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1 text-center">
|
||||
<InstanceStatus remote={remote} />
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark"
|
||||
onClick={() => openExternalRover(remote, '', { includeSettings })}
|
||||
>
|
||||
Visit server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{online && features.length ? (
|
||||
<div className="flex flex-wrap justify-center gap-0.5">
|
||||
{features.map((feature) => (
|
||||
<span key={feature} className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{online ? children : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteMediaStrip({ remote }) {
|
||||
const roomCameras = Array.isArray(remote.roomCameras) ? remote.roomCameras.filter((camera) => camera.snapshotUrl) : [];
|
||||
if (!roomCameras.length) return null;
|
||||
return (
|
||||
<div className="flex gap-0.5 overflow-x-auto pb-0.5">
|
||||
{roomCameras.map((camera) => (
|
||||
<div key={camera.id} className="surface-muted w-28 shrink-0 overflow-hidden">
|
||||
<img src={camera.snapshotUrl} alt={camera.name || camera.id} className="h-14 w-full bg-black object-cover" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalInstancesCompact() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
|
||||
if (!enabled) return null;
|
||||
if (!visible.length) return null;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<button type="button" className="button-dark w-full" onClick={() => setExpanded((value) => !value)}>
|
||||
{expanded ? 'Hide external' : `Show external (${visible.length})`}
|
||||
</button>
|
||||
<button type="button" className="button-dark w-full" onClick={() => setPopupOpen(true)}>
|
||||
Browse servers
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="space-y-0.5">
|
||||
{visible.map((remote) =>
|
||||
remote.online ? (
|
||||
<RoverQueuesPanel
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
) : (
|
||||
<InstancePanel key={remote.url} remote={remote} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{popupOpen ? <InterInstancePopup onClose={() => setPopupOpen(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InterInstancePopup({ onClose }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
|
||||
<InterInstanceBrowserFrame
|
||||
onClose={onClose}
|
||||
className="max-w-[calc(100vw-0.5rem)]"
|
||||
bodyClassName="max-h-[82vh] overflow-y-auto p-0.5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InterInstanceCards({ instances, centered = false }) {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex flex-wrap justify-center gap-0.5',
|
||||
centered && 'mx-auto w-full max-w-3xl',
|
||||
)}>
|
||||
{instances.map((remote) => (
|
||||
<div key={remote.url} className="w-80 max-w-full shrink-0 space-y-0.5">
|
||||
<InstancePanel remote={remote}>
|
||||
{/*
|
||||
The large browser should read as one card per external server:
|
||||
the server metadata, room snapshots, and the exact existing rover
|
||||
queues panel are grouped together here. RoverQueuesPanel keeps its
|
||||
own CardFrame and default title, so this component only controls
|
||||
where that already-existing panel is placed.
|
||||
*/}
|
||||
<>
|
||||
<RemoteMediaStrip remote={remote} />
|
||||
<RoverQueuesPanel
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
</>
|
||||
</InstancePanel>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InterInstanceBrowserFrame({
|
||||
onClose = null,
|
||||
hideWhenEmpty = false,
|
||||
className = '',
|
||||
bodyClassName = 'p-0.5',
|
||||
centered = false,
|
||||
}) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
if (!enabled) return null;
|
||||
if (!instances.length && hideWhenEmpty) return null;
|
||||
const actions = onClose ? (
|
||||
<button type="button" className="button-dark" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
) : null;
|
||||
/*
|
||||
This frame is shared by the popup and the admin/lockdown overlay. Keeping
|
||||
the wrapper here means those surfaces get the same external-instance card
|
||||
without placing it inside the login card or duplicating layout behavior.
|
||||
*/
|
||||
return (
|
||||
<CardFrame
|
||||
title="External instances"
|
||||
actions={actions}
|
||||
className={className}
|
||||
bodyClassName={bodyClassName}
|
||||
clipOverflow={false}
|
||||
>
|
||||
{instances.length ? (
|
||||
<InterInstanceCards instances={instances} centered={centered} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No external instances discovered.</p>
|
||||
)}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InterInstancePanel({ compact = false, centered = false }) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
if (!enabled) return null;
|
||||
if (compact) return <ExternalInstancesCompact />;
|
||||
if (!instances.length) {
|
||||
return (
|
||||
<CardFrame title="External instances" bodyClassName="p-0.5 text-sm">
|
||||
<p className="text-slate-500">No external instances discovered.</p>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
return <InterInstanceCards instances={instances} centered={centered} />;
|
||||
}
|
||||
@@ -11,8 +11,21 @@ import {
|
||||
normalizeBinaryPayload,
|
||||
normalizeKinectStatus,
|
||||
} from './utils.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
export default function KinectPanel() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'kinect'));
|
||||
|
||||
/*
|
||||
Kinect is optional local hardware. Keep the feature gate inside the panel so
|
||||
disabled installs do not need special cases in every Activities layout.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <KinectPanelContent />;
|
||||
}
|
||||
|
||||
function KinectPanelContent() {
|
||||
const socket = useSocket();
|
||||
const status = useSessionSelector((state) => normalizeKinectStatus(state.session?.kinect));
|
||||
const [activeView, setActiveView] = useState('3d');
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
function badgeClass(tone) {
|
||||
if (tone === 'good') return 'bg-emerald-600 text-white';
|
||||
@@ -21,6 +22,18 @@ function positionLabel(value) {
|
||||
}
|
||||
|
||||
export default function LiftCard() {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'lift'));
|
||||
|
||||
/*
|
||||
Lift is an optional Home Assistant-backed hardware feature. The card owns
|
||||
that existence check so disabled installs do not need layout-level guards.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
return <LiftCardContent />;
|
||||
}
|
||||
|
||||
function LiftCardContent() {
|
||||
/*
|
||||
The lift card is a complete Activities-tab feature, so it reads the shared
|
||||
lift state and socket actions directly. Keeping that wiring inside the card
|
||||
|
||||
@@ -28,7 +28,7 @@ function getSpeedModeConfig(speedMode) {
|
||||
return DRIVE_PAD_SPEED_MODES.find((mode) => mode.id === speedMode) || DRIVE_PAD_SPEED_MODES[1];
|
||||
}
|
||||
|
||||
export default function ControlPadPanel({ disabled = false }) {
|
||||
export default function ControlPadPanel({ compact = false, disabled = false }) {
|
||||
const rawKeymap = useControlSelector((control) => control.state.keymap);
|
||||
const { setCameraPrecisionMode, setDriveVector, registerInputState } = useControlActions();
|
||||
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
|
||||
@@ -163,8 +163,8 @@ export default function ControlPadPanel({ disabled = false }) {
|
||||
}, [disabled, setCameraPrecisionMode, stopDrivePad]);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md">
|
||||
<div className="mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 p-0.5">
|
||||
<div className={`mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md ${compact ? 'h-full' : ''}`}>
|
||||
<div className={`mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 ${compact ? 'p-0.25' : 'p-0.5'}`}>
|
||||
{DRIVE_PAD_SPEED_MODES.map((mode) => {
|
||||
const active = speedMode === mode.id;
|
||||
const speedValue =
|
||||
@@ -177,7 +177,7 @@ export default function ControlPadPanel({ disabled = false }) {
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
className={`mobile-touch-control min-h-9 rounded-md px-1 text-xs font-semibold ${
|
||||
className={`mobile-touch-control rounded-md px-1 text-xs font-semibold ${compact ? 'min-h-7' : 'min-h-9'} ${
|
||||
active
|
||||
? 'bg-cyan-300 text-slate-950'
|
||||
: 'bg-slate-800 text-slate-200'
|
||||
@@ -186,7 +186,7 @@ export default function ControlPadPanel({ disabled = false }) {
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="block leading-tight">{mode.label}</span>
|
||||
<span className="block font-mono text-[0.7rem] leading-tight">{speedValue}</span>
|
||||
{!compact ? <span className="block font-mono text-[0.7rem] leading-tight">{speedValue}</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -194,6 +194,7 @@ export default function ControlPadPanel({ disabled = false }) {
|
||||
<div className="mobile-touch-control min-h-0 flex-1">
|
||||
<FloatingJoystick
|
||||
activeInputLabel={activeInputLabel}
|
||||
compact={compact}
|
||||
disabled={disabled}
|
||||
onCellChange={handleCellChange}
|
||||
onStop={() => stopDrivePad('stop')}
|
||||
|
||||
@@ -119,7 +119,13 @@ function FloatingPadOverlay({ center, size, activeCellId }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FloatingJoystick({ activeInputLabel = 'stop', disabled, onCellChange, onStop }) {
|
||||
export default function FloatingJoystick({
|
||||
activeInputLabel = 'stop',
|
||||
compact = false,
|
||||
disabled,
|
||||
onCellChange,
|
||||
onStop,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
const activePadRef = useRef(null);
|
||||
@@ -209,7 +215,7 @@ export default function FloatingJoystick({ activeInputLabel = 'stop', disabled,
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className="mobile-touch-control mobile-drag-control relative flex h-full min-h-[10rem] w-full select-none items-center justify-center overflow-hidden text-slate-100"
|
||||
className={`mobile-touch-control mobile-drag-control relative flex h-full w-full select-none items-center justify-center overflow-hidden text-slate-100 ${compact ? 'min-h-[7rem]' : 'min-h-[10rem]'}`}
|
||||
// Pointer drags are the whole control model here, so this inline value
|
||||
// reinforces the utility class even if future class churn changes it.
|
||||
style={{ touchAction: 'none' }}
|
||||
@@ -222,17 +228,23 @@ export default function FloatingJoystick({ activeInputLabel = 'stop', disabled,
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 border-b border-slate-700 bg-slate-950 px-1.5 py-0.5 text-center">
|
||||
{/* The readout lives inside the pointer target instead of above it, so the
|
||||
visual indicator does not consume any non-drivable space on small phones. */}
|
||||
<span className="font-mono text-xs font-semibold text-cyan-200">
|
||||
{activeInputLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pointer-events-none flex flex-col items-center gap-0.5 px-2 pt-5 text-center">
|
||||
<span className="text-sm font-semibold text-slate-100">drive pad</span>
|
||||
<span className="text-xs leading-tight text-slate-300">hold and drag</span>
|
||||
</div>
|
||||
{!compact ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 border-b border-slate-700 bg-slate-950 px-1.5 py-0.5 text-center">
|
||||
{/* The readout lives inside the pointer target instead of above it, so the
|
||||
visual indicator does not consume any non-drivable space on small phones. */}
|
||||
<span className="font-mono text-xs font-semibold text-cyan-200">
|
||||
{activeInputLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!compact ? (
|
||||
<div className="pointer-events-none flex flex-col items-center gap-0.5 px-2 pt-5 text-center">
|
||||
<span className="text-sm font-semibold text-slate-100">drive pad</span>
|
||||
<span className="text-xs leading-tight text-slate-300">hold and drag</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none h-8 w-8 rounded-full border border-cyan-300/60 bg-cyan-300/20" />
|
||||
)}
|
||||
</div>
|
||||
{activePad ? (
|
||||
<FloatingPadOverlay
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import { InterInstanceBrowserFrame } from '../InterInstancePanel/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown']);
|
||||
@@ -32,6 +34,7 @@ export default function ModeGateOverlay() {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const restricted = RESTRICTED_MODES.has(mode);
|
||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
/*
|
||||
@@ -62,35 +65,47 @@ export default function ModeGateOverlay() {
|
||||
const details = getModeDetails(mode);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black px-0.5 py-0.5">
|
||||
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 overflow-y-auto bg-black px-0.5 py-0.5">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col items-center justify-center gap-0.5 lg:flex-row lg:items-center">
|
||||
<div className="surface w-full max-w-md shrink-0 space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
</div>
|
||||
<div className="surface-muted space-y-0.5">
|
||||
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
|
||||
<p className="text-lg font-semibold text-slate-100">
|
||||
{reason ? reason : 'No reason set.'}
|
||||
</p>
|
||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!" />
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
</div>
|
||||
|
||||
{/* <p className="text-xs text-slate-500">
|
||||
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||
changes or after a successful login.
|
||||
</p> */}
|
||||
</div>
|
||||
<div className="surface-muted space-y-0.5">
|
||||
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
|
||||
<p className="text-lg font-semibold text-slate-100">
|
||||
{reason ? reason : 'No reason set.'}
|
||||
</p>
|
||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<div className="w-full justify-center items-center">
|
||||
<SocialButton id="discord" label="Join our Discord server for updates!"/>
|
||||
</div>
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
<ChatPanel nicknameLayout="stacked" />
|
||||
</div>
|
||||
|
||||
{/* <p className="text-xs text-slate-500">
|
||||
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||
changes or after a successful login.
|
||||
</p> */}
|
||||
{interInstanceEnabled ? (
|
||||
/*
|
||||
The external browser is a sibling of the login card, not content
|
||||
inside it. hideWhenEmpty lets the login card remain centered when
|
||||
the directory has no other servers to offer.
|
||||
*/
|
||||
<InterInstanceBrowserFrame
|
||||
hideWhenEmpty
|
||||
className="max-w-[calc(100vw-0.5rem)]"
|
||||
bodyClassName="max-h-[86vh] overflow-y-auto p-0.5"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user