Compare commits

...
Author SHA1 Message Date
legop3 55e8235b02 Keep laptop Google TTS changes out of Pi profile 2026-07-07 00:23:50 -04:00
legop3 77de628c0b Restore Pi Google TTS asset behavior 2026-07-07 00:23:29 -04:00
legop3 f31b21559c Add laptop-only Chrome TTS daemon 2026-07-07 00:23:06 -04:00
legop3 f7514e71cc Restore Pi Chrome TTS daemon unchanged 2026-07-07 00:22:47 -04:00
legop3 f4683cd47d Install GCC runtime for laptop Chrome TTS 2026-07-07 00:14:45 -04:00
legop3 2beb1498fa Preload compiler runtimes for Chrome TTS 2026-07-07 00:14:33 -04:00
legop3 d349df2432 Share Google TTS asset installer with laptop profile 2026-07-07 00:01:51 -04:00
legop3 11340bf3f6 Make laptop ALSA routing match Pi rover 2026-07-07 00:01:26 -04:00
legop3 e6c4931210 Make Debian laptop audio setup appliance-like 2026-07-07 00:01:13 -04:00
legop3 8f0ac358d6 ui adjustments 2026-07-06 21:52:20 -04:00
legop3 4204a66549 ui adjustments 2026-07-06 20:13:45 -04:00
legop3 a8bff428c2 interinstance styling changes yay 2026-07-06 19:10:26 -04:00
legop3 69b49ae1d6 inter-instance UI redo 2026-07-06 15:58:40 -04:00
legop3 07ad43f42f private rover security! and styling updaes 2026-07-06 15:26:43 -04:00
legop3 f9f87c00d3 inter-instance! 2026-07-06 15:17:24 -04:00
legop3 6f6325f477 plannings 2026-07-05 23:37:34 -04:00
legop3 0b3c7869af inter-instance plannings 2026-07-05 23:27:26 -04:00
legop3 b526beb712 driver removal information finaly! 2026-07-05 22:45:38 -04:00
legop3 df22ac6d81 plannings 2026-07-05 22:12:28 -04:00
legop3 6c06275c6d Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-07-05 21:51:35 -04:00
legop3 3aea6d4766 private rover virtual wall changes 2026-07-05 21:51:33 -04:00
legop3 3e632ac607 Remove virtual wall support task for private rovers
Removed the task for adding virtual wall support for private rovers.
2026-07-05 18:58:53 -04:00
legop3 d77ec54bc9 virtual wall private rover safety 2026-07-05 14:57:26 -04:00
legop3 40e8adf15a big overhaul for server and webui feature matching, things default to disabled and disappear from UI when disabled. 2026-07-05 14:42:43 -04:00
legop3 29bf4cc5d2 plannings 2026-07-05 13:18:39 -04:00
legop3 b083938338 light lock rs command 2026-07-04 21:07:13 -04:00
legop3 5cade7a941 Merge pull request #12 from legop3/laptoprover
Laptoprover
2026-07-04 20:24:17 -04:00
legop3 00277667d6 Update wikiUrl for Green Ball Container 2026-07-04 15:36:47 -04:00
legop3 83a6910c25 Add new entity 'o009' to barcode registry 2026-07-01 13:19:13 -04:00
legop3 295d01f7cc full speed turbo i guess.... 2026-07-01 01:48:39 -04:00
legop3 d8e63bdf2e new default speeds because faster rovers 2026-06-30 22:48:46 -04:00
legop3 fa4852b93c Merge pull request #11 from legop3/laptoprover
Laptoprover
2026-06-30 22:08:00 -04:00
71 changed files with 2899 additions and 562 deletions
+71 -61
View File
@@ -1,88 +1,98 @@
# ALSA routing for the Debian laptop rover profile. # Dedicated ALSA routing for the Debian laptop rover profile.
# #
# This file intentionally mirrors the logical device names used by the Pi rover # This intentionally mirrors pi/asound.conf as closely as a normal PC can:
# audio setup. roverd can keep sending horn audio to "horn", forwarded browser # one fixed hardware card, one dmix playback engine, separate softvol controls
# audio to "forward", and TTS to ALSA's default playback path without caring # for TTS/horn/forwarded audio, and a raw capture alias for the rover mic.
# which physical sound card is underneath the profile. #
# 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, change the hw:0,0/card 0 references below to the card shown by:
# aplay -l
# arecord -l
pcm.roverd_playback { # Mix multiple playback clients in software with a fixed low-cost format.
type plug pcm.dmixer {
type dmix
# Use the system's first normal ALSA playback device as the physical sink. ipc_key 1024
# This avoids referencing "default" here, because this file replaces ipc_perm 0666
# pcm.!default below and using it as a slave would recurse. slave {
slave.pcm "sysdefault" pcm "hw:0,0"
} format S16_LE
rate 16000
pcm.roverd_capture { channels 1
type plug period_time 0
period_size 1024
# The media publisher records from "default"; with pcm.!default below that buffer_size 4096
# capture side resolves here. Keeping capture separate from playback lets the }
# asym default expose ordinary microphone input while playback goes through
# the TTS softvol path.
slave.pcm "sysdefault"
} }
# TTS volume control (used by default playback path).
pcm.tts_softvol { pcm.tts_softvol {
type softvol type softvol
slave.pcm "roverd_playback" slave.pcm "dmixer"
control { control {
name "TTSMaster" name "TTSMaster"
card 0 card 0
} }
min_dB -60.0 min_dB -60.0
max_dB 12.0 max_dB 12.0
} }
# Horn volume control.
pcm.horn_softvol { pcm.horn_softvol {
type softvol type softvol
slave.pcm "roverd_playback" slave.pcm "dmixer"
control { control {
name "HornMaster" name "HornMaster"
card 0 card 0
} }
min_dB -60.0 min_dB -60.0
max_dB 12.0 max_dB 12.0
} }
# Forwarded audio volume control.
pcm.forward_softvol { pcm.forward_softvol {
type softvol type softvol
slave.pcm "roverd_playback" slave.pcm "dmixer"
control { control {
name "ForwardMaster" name "ForwardMaster"
card 0 card 0
} }
min_dB -60.0 min_dB -60.0
max_dB 12.0 max_dB 12.0
} }
# Per-source playback PCMs.
pcm.tts { pcm.tts {
type plug type plug
slave.pcm "tts_softvol" slave.pcm "tts_softvol"
} }
pcm.horn { pcm.horn {
type plug type plug
slave.pcm "horn_softvol" slave.pcm "horn_softvol"
} }
pcm.forward { pcm.forward {
type plug type plug
slave.pcm "forward_softvol" slave.pcm "forward_softvol"
} }
pcm.!default { # Capture alias used by laptop rover config defaults.
type asym pcm.rovermic {
type plug
slave.pcm "hw:0,0"
}
# Existing TTS engines play to their default ALSA output, so default playback # Defaults: TTS direct playback + raw capture on the dedicated laptop sound card.
# is intentionally the TTS path. This preserves the current TTS execution pcm.!default {
# model while still making the TTS volume control meaningful on laptops. type asym
playback.pcm "tts" playback.pcm "tts"
capture.pcm "roverd_capture" capture.pcm "rovermic"
} }
ctl.!default { ctl.!default {
type hw type hw
card 0 card 0
} }
+202
View File
@@ -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())
+129 -14
View File
@@ -1,23 +1,63 @@
#!/usr/bin/env bash #!/usr/bin/env bash
install_debian_laptop_deps() { install_debian_laptop_deps() {
# This profile is deliberately Debian-only. Using apt directly is simpler # The laptop rover is a dedicated appliance, not a normal desktop/laptop audio
# than adding a fake cross-distro layer, and it keeps the installed package # install. Keep this package set intentionally close to the Pi profile so the
# set easy to inspect on the actual rover laptop. # same roverd TTS/playback/capture code paths are available on both targets.
if command -v ffmpeg >/dev/null 2>&1 \ if command -v ffmpeg >/dev/null 2>&1 \
&& command -v arecord >/dev/null 2>&1 \ && command -v arecord >/dev/null 2>&1 \
&& command -v aplay >/dev/null 2>&1 \ && command -v aplay >/dev/null 2>&1 \
&& command -v amixer >/dev/null 2>&1 \ && command -v amixer >/dev/null 2>&1 \
&& command -v v4l2-ctl >/dev/null 2>&1 \ && command -v v4l2-ctl >/dev/null 2>&1 \
&& command -v flite >/dev/null 2>&1 \ && command -v flite >/dev/null 2>&1 \
&& command -v espeak >/dev/null 2>&1; then && command -v espeak >/dev/null 2>&1 \
log "Debian laptop media/audio dependencies already installed; skipping apt install" && command -v python3 >/dev/null 2>&1 \
&& command -v curl >/dev/null 2>&1 \
&& command -v xz >/dev/null 2>&1 \
&& command -v unzip >/dev/null 2>&1 \
&& ldconfig -p 2>/dev/null | grep -q 'libgcc_s\.so\.1' \
&& ldconfig -p 2>/dev/null | grep -q 'libstdc\+\+\.so\.6' \
&& ldconfig -p 2>/dev/null | grep -q 'libc++\.so\.1' \
&& ldconfig -p 2>/dev/null | grep -q 'libc++abi\.so\.1'; then
log "Debian laptop media/audio/TTS dependencies already installed; skipping apt install"
return return
fi fi
log "Installing Debian laptop dependencies (ffmpeg, ALSA tools, V4L2 tools, flite, espeak)..." log "Installing Debian laptop rover dependencies (ffmpeg, ALSA tools, V4L2 tools, flite/espeak, Chrome TTS runtime deps)..."
apt-get update apt-get update
apt-get install -y --no-install-recommends ffmpeg alsa-utils v4l-utils ca-certificates flite espeak apt-get install -y --no-install-recommends \
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1 libc++abi1 \
|| apt-get install -y --no-install-recommends \
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1-14 libc++abi1-14
}
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
} }
install_debian_laptop_audio_support() { install_debian_laptop_audio_support() {
@@ -26,17 +66,92 @@ install_debian_laptop_audio_support() {
return return
fi 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 install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf
log "Installed Debian laptop ALSA config to /etc/asound.conf" log "Installed dedicated Debian laptop ALSA config to /etc/asound.conf"
log "ALSA config updated; restarting audio clients or rebooting is recommended before testing laptop audio"
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
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_profile() {
install_debian_laptop_deps install_debian_laptop_deps
disable_debian_laptop_desktop_audio_stack
install_debian_laptop_audio_support install_debian_laptop_audio_support
} }
+28 -22
View File
@@ -177,17 +177,20 @@ type PrivateConfig struct {
} }
type PrivateSafetyConfig struct { type PrivateSafetyConfig struct {
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"` SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"` SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"` HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"` OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"` HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"` BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"` BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"` CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"` CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"` CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"` 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 { type Config struct {
@@ -303,17 +306,20 @@ func LoadConfig(path string) (*Config, error) {
Private: PrivateConfig{ Private: PrivateConfig{
Enabled: false, Enabled: false,
Safety: PrivateSafetyConfig{ Safety: PrivateSafetyConfig{
SpeedLimitEnabled: false, SpeedLimitEnabled: false,
SpeedLimitMaxWheelMMs: 250, SpeedLimitMaxWheelMMs: 250,
HardOvercurrentEnabled: false, HardOvercurrentEnabled: false,
OvercurrentStopMs: 300, OvercurrentStopMs: 300,
HardBumpEnabled: false, HardBumpEnabled: false,
BumpBackoffSpeed: 250, BumpBackoffSpeed: 250,
BumpBackoffMs: 350, BumpBackoffMs: 350,
CliffEnabled: false, CliffEnabled: false,
CliffBackoffSpeed: 250, CliffBackoffSpeed: 250,
CliffBackoffMs: 500, CliffBackoffMs: 500,
TriggerCooldownMs: 800, VirtualWallEnabled: true,
VirtualWallBackoffSpeed: 250,
VirtualWallBackoffMs: 500,
TriggerCooldownMs: 800,
}, },
}, },
} }
@@ -95,4 +95,10 @@ private:
cliffEnabled: false cliffEnabled: false
cliffBackoffSpeed: 250 cliffBackoffSpeed: 250
cliffBackoffMs: 500 cliffBackoffMs: 500
# Virtual walls are default-on for private rovers because they mark a
# deliberate boundary, and the server can escape by reversing the last
# commanded wheel directions instead of always backing straight up.
virtualWallEnabled: true
virtualWallBackoffSpeed: 250
virtualWallBackoffMs: 500
triggerCooldownMs: 800 triggerCooldownMs: 800
+6
View File
@@ -104,4 +104,10 @@ private:
cliffEnabled: false cliffEnabled: false
cliffBackoffSpeed: 250 cliffBackoffSpeed: 250
cliffBackoffMs: 500 cliffBackoffMs: 500
# Virtual walls are default-on for private rovers because they mark a
# deliberate boundary, and the server can escape by reversing the last
# commanded wheel directions instead of always backing straight up.
virtualWallEnabled: true
virtualWallBackoffSpeed: 250
virtualWallBackoffMs: 500
triggerCooldownMs: 800 triggerCooldownMs: 800
+6
View File
@@ -69,4 +69,10 @@ private:
cliffEnabled: false cliffEnabled: false
cliffBackoffSpeed: 250 cliffBackoffSpeed: 250
cliffBackoffMs: 500 cliffBackoffMs: 500
# Virtual walls are default-on for private rovers because they mark a
# deliberate boundary, and the server can escape by reversing the last
# commanded wheel directions instead of always backing straight up.
virtualWallEnabled: true
virtualWallBackoffSpeed: 250
virtualWallBackoffMs: 500
triggerCooldownMs: 800 triggerCooldownMs: 800
+62
View File
@@ -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?
+8
View File
@@ -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.
+52 -30
View File
@@ -7,12 +7,27 @@ admins:
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321" discord_id: "0987654321"
lockdown: true lockdown: true
timezone: "America/New_York" timezone: "America/New_York"
interInstance:
enabled: false
directoryUrls:
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
pollIntervalMs: 30000
requestTimeoutMs: 5000
profile:
publicUrl: "https://rover.example.com"
name: "Example Rover Server"
description: "A short public description of this rover server."
color: "#38bdf8"
llmCommentary: llmCommentary:
enabled: false enabled: false
model: "qwen2.5:7b-instruct" model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434" ollamaServer: "http://127.0.0.1:11434"
frequency: 120000 frequency: 120000
overseerControl: overseerControl:
enabled: false enabled: false
# autonomous runs the existing vote-gated loop forever; directAddress only # autonomous runs the existing vote-gated loop forever; directAddress only
@@ -27,9 +42,12 @@ overseerControl:
ollamaServer: "http://127.0.0.1:11434" ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png" profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000 gateIntervalMs: 2000
barcodeGames: barcodeGames:
enabled: false
botName: "Barcode Games" botName: "Barcode Games"
profileImageUrl: "https://example.com/barcode-games.png" profileImageUrl: "https://example.com/barcode-games.png"
media: media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request # Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep # http://<base>/<roverId>/whep
@@ -49,13 +67,16 @@ audioLevels:
forwardGain: 1.0 forwardGain: 1.0
homeAssistant: homeAssistant:
enabled: false
url: "http://homeassistant.local:8123" url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN" token: "REPLACE_WITH_LONG_LIVED_TOKEN"
neato: neato:
enabled: false
# ESPHome device name, used to derive gen3 entities: # ESPHome device name, used to derive gen3 entities:
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc. # button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
device: "neato_vacuum" device: "neato_vacuum"
lift: lift:
enabled: false
# Two Home Assistant switches controlling lift direction. # Two Home Assistant switches controlling lift direction.
# Raise sequence: down off -> wait interlockMs -> up on # Raise sequence: down off -> wait interlockMs -> up on
# Lower sequence: up off -> wait interlockMs -> down on # Lower sequence: up off -> wait interlockMs -> down on
@@ -92,17 +113,20 @@ homeAssistant:
stateEquals: "toggle" stateEquals: "toggle"
cooldownMs: 1000 cooldownMs: 1000
action: "lightsLockToggle" action: "lightsLockToggle"
roomCameras: roomCameras:
- id: "lobby" enabled: false
name: "Lobby Camera" cameras:
description: "Wide shot of the staging area." - id: "lobby"
url: "http://192.168.0.50/snapshot.jpg" name: "Lobby Camera"
streamUrl: "http://192.168.0.50/stream.mjpg" description: "Wide shot of the staging area."
- id: "workshop" url: "http://192.168.0.50/snapshot.jpg"
name: "Workshop Bench" streamUrl: "http://192.168.0.50/stream.mjpg"
description: "Shows the workbench and charging docks." - id: "workshop"
url: "http://192.168.0.51/snapshot.jpg" name: "Workshop Bench"
streamUrl: "http://192.168.0.51/stream.mjpg" description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
kinect: kinect:
enabled: false enabled: false
@@ -111,6 +135,12 @@ kinect:
# camera cache; it only gates browser-requested broadcasts. # camera cache; it only gates browser-requested broadcasts.
captureCooldownMs: 10000 captureCooldownMs: 10000
buttonBox:
enabled: false
barcodeScanner:
enabled: false
discord: discord:
token: "DISCORD_BOT_TOKEN" token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to guildId: "123456789012345678" # optional; bot works in any guild it's invited to
@@ -129,23 +159,15 @@ discord:
humanAlertPing: "123456789012345678" humanAlertPing: "123456789012345678"
socials: socials:
- id: "discord" enabled: false
label: "Discord" links:
url: "https://discord.gg/your-invite" - id: "discord"
icon: "FaDiscord" label: "Discord"
color: "#5865F2" url: "https://discord.gg/your-invite"
- id: "kofi" icon: "FaDiscord"
label: "Ko-fi" color: "#5865F2"
url: "https://ko-fi.com/your-handle" - id: "kofi"
icon: "FaCoffee" label: "Ko-fi"
color: "#29ABE0" url: "https://ko-fi.com/your-handle"
- id: "wiki" icon: "FaCoffee"
label: "Wiki" color: "#29ABE0"
url: "https://wiki.example.com"
icon: "FaBook"
color: "#475569"
- id: "throne"
label: "Throne"
url: "https://throne.me/yourname"
icon: "FaCrown"
color: "#334155"
+7 -1
View File
@@ -58,10 +58,16 @@
"entityId": "printer", "entityId": "printer",
"label": "Medical thermal printer" "label": "Medical thermal printer"
}, },
"o008": { "o008": {
"type": "object", "type": "object",
"entityId": "brick", "entityId": "brick",
"label": "BRICK" "label": "BRICK"
},
"o009": {
"type": "object",
"entityId": "gbc",
"label": "Green Ball Container",
"wikiUrl": "https://wiki.otter.land/Room%20Objects/Green%20Ball%20Container"
} }
} }
} }
+1
View File
@@ -30,6 +30,7 @@ require('./src/services/videoAuthService');
require('./src/services/videoSocketService'); require('./src/services/videoSocketService');
require('./src/services/roomCameraService'); require('./src/services/roomCameraService');
require('./src/services/roverSnapshotService'); require('./src/services/roverSnapshotService');
require('./src/services/interInstanceService');
require('./src/services/humanAlertButtonService'); require('./src/services/humanAlertButtonService');
require('./src/services/embedHttpService'); require('./src/services/embedHttpService');
require('./src/services/logStreamService'); require('./src/services/logStreamService');
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
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DRqsCa1W.js"></script> <script type="module" crossorigin src="/assets/index-CT_oLzKM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css"> <link rel="stylesheet" crossorigin href="/assets/index-AEvF9veu.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+100
View File
@@ -0,0 +1,100 @@
// 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 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),
};
}
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 // Reward Definition: Light Strobe
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch. // Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
const STROBE_MS = 30 * 1000; const STROBE_MS = 60 * 1000;
const TICK_MS = 500; const TICK_MS = 1500;
let activeTimer = null; let activeTimer = null;
@@ -44,7 +44,7 @@ module.exports = {
goal: 400, goal: 400,
async run(ctx) { async run(ctx) {
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false }); startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' }); ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 60 seconds.' });
}, },
async recover(ctx, effect) { async recover(ctx, effect) {
if (!effect || Number(effect.endsAt || 0) <= Date.now()) { if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
@@ -13,6 +13,37 @@ const assignments = new Map(); // socketId -> roverId
const waiting = new Set(); // socketIds waiting for placement const waiting = new Set(); // socketIds waiting for placement
const assignmentEvents = new EventEmitter(); const assignmentEvents = new EventEmitter();
function normalizeRemovalMessage(message, fallback) {
/*
Removal notices are shown directly in the driving UI, so the server trims
caller-provided text before emitting it. Keeping this normalization close to
the release helper makes every forced-removal path use the same readable
fallback instead of forcing each caller to duplicate defensive string checks.
*/
const clean = String(message || '').trim();
return clean || fallback;
}
function emitRemovalNotice(socket, notice = {}) {
/*
The browser may lose its rover assignment in the same server tick that the
reason is generated. Sending a dedicated event before releasing control lets
the client preserve the explanation even after normal session sync says the
user no longer has an assigned rover.
*/
if (!socket) return;
const roverId = String(notice.roverId || '').trim() || null;
const message = normalizeRemovalMessage(notice.message, 'You were removed from the rover.');
socket.emit('session:roverRemovalNotice', {
roverId,
title: normalizeRemovalMessage(notice.title, 'Removed from rover'),
message,
reasonCode: String(notice.reasonCode || 'removed').trim() || 'removed',
actor: notice.actor || null,
ts: Date.now(),
});
}
io.on('connection', (socket) => { io.on('connection', (socket) => {
socketRefs.set(socket.id, socket); socketRefs.set(socket.id, socket);
socket.on('disconnect', () => { socket.on('disconnect', () => {
@@ -176,6 +207,18 @@ function forceRelease(roverId, socketId) {
assignmentEvents.emit('update', socketId); assignmentEvents.emit('update', socketId);
} }
function forceReleaseWithNotice(roverId, socketId, notice = {}) {
/*
This is the one public path for moderation-style removals. It deliberately
emits the explanation before forceRelease mutates assignment state, because
session sync listeners can update the UI immediately after the release and
the UI needs the reason to already be in local state.
*/
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
emitRemovalNotice(socket, { ...notice, roverId });
forceRelease(roverId, socketId);
}
function pickRover(socket, options = {}) { function pickRover(socket, options = {}) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
@@ -266,6 +309,7 @@ module.exports = {
assignmentEvents, assignmentEvents,
describeAssignment, describeAssignment,
forceRelease, forceRelease,
forceReleaseWithNotice,
rerollAssignments, rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null, getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => { moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
+40 -27
View File
@@ -6,6 +6,7 @@
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeGameService'); const logger = require('../../globals/logger').child('barcodeGameService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { sendSystemMessage } = require('../chatService'); const { sendSystemMessage } = require('../chatService');
const { getActiveDrivers } = require('../turnService'); const { getActiveDrivers } = require('../turnService');
@@ -30,6 +31,7 @@ const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game])); const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
const config = loadConfig(); const config = loadConfig();
const barcodeGamesConfig = config.barcodeGames || {}; const barcodeGamesConfig = config.barcodeGames || {};
const enabled = isFeatureEnabled('barcodeGames');
const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games'; const botName = String(barcodeGamesConfig.botName || barcodeGamesConfig.name || 'Barcode Games').trim() || 'Barcode Games';
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null; const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
@@ -1120,34 +1122,43 @@ function broadcastState() {
}); });
} }
io.on('connection', (socket) => { if (enabled) {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => { /*
socket.join(GAME_SOCKET_ROOM); Barcode games are an optional layer on top of the physical scanner station.
const state = buildStatePayload(socket); Keep sockets and scan subscriptions behind the feature gate so disabled
socket.emit('barcodeGame:state', state); installs do not run invisible game state.
cb({ success: true, 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 { try {
cb(setVote(socket, gameId)); handleScan(event.payload);
} catch (err) { } catch (err) {
logger.warn('Barcode game vote failed', { error: err.message, gameId }); // Scanner input should never be able to take down the server. Game failures
cb({ error: err.message || 'barcode game vote failed' }); // 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');
}
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 });
}
});
module.exports = { module.exports = {
buildStatePayload, buildStatePayload,
@@ -1155,8 +1166,10 @@ module.exports = {
setVote, setVote,
}; };
setInterval(() => { if (enabled) {
if (settleActiveGameIfNeeded()) { setInterval(() => {
broadcastState(); if (settleActiveGameIfNeeded()) {
} broadcastState();
}, GAME_TICK_MS).unref?.(); }
}, GAME_TICK_MS).unref?.();
}
@@ -5,6 +5,7 @@ const fs = require('fs');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService'); const logger = require('../../globals/logger').child('barcodeScannerService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isFeatureEnabled } = require('../../helpers/features');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache'); const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
@@ -14,6 +15,7 @@ const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
const RECENT_SCAN_LIMIT = 8; const RECENT_SCAN_LIMIT = 8;
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/; const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
const SCANNER_SOCKET_ROOM = 'barcode-scanner'; const SCANNER_SOCKET_ROOM = 'barcode-scanner';
const enabled = isFeatureEnabled('barcodeScanner');
let lastKnownGoodRegistry = null; let lastKnownGoodRegistry = null;
let lastRegistryError = null; let lastRegistryError = null;
@@ -310,39 +312,53 @@ async function applyScan(rawCode) {
return { result }; return { result };
} }
io.on('connection', (socket) => { if (enabled) {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => { /*
socket.join(SCANNER_SOCKET_ROOM); Barcode scanning is tied to a physical scanner station. Disabled installs
socket.emit('barcode:state', buildStatePayload()); should not create the registry file or expose scanner socket commands.
cb({ success: true, state: buildStatePayload() }); */
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 = () => {}) => { modeEvents.on('change', () => {
try { // Access-mode changes affect whether the scanner page should beep when it
const { result } = await applyScan(code); // submits a code, so scanner clients need a fresh state packet even without a
cb({ success: true, result, state: buildStatePayload() }); // new scan.
} catch (err) { broadcastState();
// 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', () => { loadRegistryForScan();
// Access-mode changes affect whether the scanner page should beep when it } else {
// submits a code, so scanner clients need a fresh state packet even without a logger.info('Barcode scanner disabled by config');
// new scan. }
broadcastState();
});
loadRegistryForScan();
module.exports = { module.exports = {
REGISTRY_PATH, REGISTRY_PATH,
applyScan, applyScan: (...args) => {
if (!enabled) throw new Error('Barcode scanner is disabled');
return applyScan(...args);
},
buildStatePayload, buildStatePayload,
getRegistrySnapshot, getRegistrySnapshot: () => {
if (!enabled) return { registry: null, error: 'barcode scanner disabled' };
return getRegistrySnapshot();
},
}; };
+34 -14
View File
@@ -4,6 +4,7 @@
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('buttonBoxService'); const logger = require('../../globals/logger').child('buttonBoxService');
const { isFeatureEnabled } = require('../../helpers/features');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const { getRewardById, listRewards } = require('../../rewards'); const { getRewardById, listRewards } = require('../../rewards');
@@ -28,6 +29,7 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json'); const STORE_PATH = resolveDataPath('buttonbox-state.json');
const BUTTON_COUNT = 4; const BUTTON_COUNT = 4;
const STORE_VERSION = 1; const STORE_VERSION = 1;
const enabled = isFeatureEnabled('buttonBox');
const store = createButtonBoxStore({ const store = createButtonBoxStore({
logger, logger,
@@ -62,21 +64,39 @@ const core = createButtonBoxCore({
store, store,
}); });
registerButtonBoxRoute({ if (enabled) {
app, /*
logger, The button box is physical local hardware, so disabled public installs
buttonCount: BUTTON_COUNT, should not expose its LAN-only press endpoint or initialize its reward file.
normalizeIp, */
isLocalNetwork, registerButtonBoxRoute({
applyPress: core.applyPress, app,
}); logger,
buttonCount: BUTTON_COUNT,
normalizeIp,
isLocalNetwork,
applyPress: core.applyPress,
});
store.loadState(); store.loadState();
core.recoverEffects().catch((err) => { core.recoverEffects().catch((err) => {
logger.warn('Button box effect recovery failed', err.message); logger.warn('Button box effect recovery failed', err.message);
}); });
} else {
logger.info('Button box disabled by config');
}
module.exports = { module.exports = {
getButtonBoxState: store.getStateClone, getButtonBoxState: () => {
addButtonBoxCount: core.addCount, /*
Session sync still includes a buttonBox key for a stable payload shape,
but disabled mode must not create/read the persisted button-box store.
*/
if (!enabled) return { buttons: [] };
return store.getStateClone();
},
addButtonBoxCount: (...args) => {
if (!enabled) throw new Error('Button box is disabled');
return core.addCount(...args);
},
}; };
@@ -9,6 +9,7 @@ const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService'); const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const { const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
@@ -162,6 +163,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
getAdminReason, getAdminReason,
setAdminReason, setAdminReason,
clearAdminReason, clearAdminReason,
// Web chat builds its own command-router instance for the sending socket.
// Supplying the same Home Assistant service used by Discord keeps `rs
// lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers.
homeAssistantService,
getGuildConfig: () => null, getGuildConfig: () => null,
setGuildConfig: () => null, setGuildConfig: () => null,
removeGuildConfig: () => null, removeGuildConfig: () => null,
@@ -11,6 +11,8 @@ function formatHelp() {
'`rs bridge here <global|private>` — set chat bridge to this channel', '`rs bridge here <global|private>` — set chat bridge to this channel',
'`rs bridge mode <global|private>` — change chat bridge mode', '`rs bridge mode <global|private>` — change chat bridge mode',
'`rs bridge off` — disable chat bridge for this server', '`rs bridge off` — disable chat bridge for this server',
'`rs lights <status|lock|unlock>` — show or change room light lock state',
'`rs kick <user> [reason]` — remove a user from their current rover; use `user | reason` for multi-word names',
'`rs lock <rover>` — lock a rover; rover names can be fuzzy', '`rs lock <rover>` — lock a rover; rover names can be fuzzy',
'`rs unlock <rover>` — unlock 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 mode <open|turns|admin|lockdown>` — change server mode',
@@ -12,6 +12,8 @@ const { createVerifyCommand } = require('./verify');
const { createDeterCommand } = require('./deter'); const { createDeterCommand } = require('./deter');
const { createBridgeCommand } = require('./bridge'); const { createBridgeCommand } = require('./bridge');
const { createTimeStatusCommand } = require('./timeStatus'); const { createTimeStatusCommand } = require('./timeStatus');
const { createLightsCommand } = require('./lights');
const { createKickCommand } = require('./kick');
function createCommandHandlers(deps) { function createCommandHandlers(deps) {
const { const {
@@ -33,6 +35,8 @@ function createCommandHandlers(deps) {
const handleDeterCommand = createDeterCommand(deps); const handleDeterCommand = createDeterCommand(deps);
const handleBridgeCommand = createBridgeCommand(deps); const handleBridgeCommand = createBridgeCommand(deps);
const handleTimeStatusCommand = createTimeStatusCommand(deps); const handleTimeStatusCommand = createTimeStatusCommand(deps);
const handleLightsCommand = createLightsCommand(deps);
const handleKickCommand = createKickCommand(deps);
async function handleCommand(message) { async function handleCommand(message) {
if (message.author.bot) return; if (message.author.bot) return;
@@ -52,7 +56,11 @@ function createCommandHandlers(deps) {
const isAdmin = isAdminUser(message.author.id); const isAdmin = isAdminUser(message.author.id);
const isLockdownAdmin = isLockdownAdminUser(message.author.id); const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const mode = getMode(); 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') { 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 } }); await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
@@ -74,6 +82,10 @@ function createCommandHandlers(deps) {
return handleReplayCommand(message, tokens.join(' ')); return handleReplayCommand(message, tokens.join(' '));
case 'bridge': case 'bridge':
return handleBridgeCommand(message, tokens); return handleBridgeCommand(message, tokens);
case 'lights':
return handleLightsCommand(message, tokens);
case 'kick':
return handleKickCommand(message, rest);
case 'lock': case 'lock':
return handleLockCommand(message, rest, true); return handleLockCommand(message, rest, true);
case 'unlock': case 'unlock':
@@ -0,0 +1,136 @@
// 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: `rs 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) {
const query = normalizeSearchText(selector);
if (!query) return { error: 'Specify a user to kick. Example: `rs 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 }) {
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);
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,71 @@
// 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 }) {
return async function handleLightsCommand(message, tokens = []) {
// Defaulting to status makes `rs lights` safe to type while still exposing
// the explicit mutating forms as `rs lights lock` and `rs lights unlock`.
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 `rs lights lock`, `rs lights unlock`, or `rs 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 };
@@ -19,6 +19,7 @@ const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService'); const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const { const {
getGuildConfig, getGuildConfig,
listGuildConfigs, listGuildConfigs,
@@ -136,6 +137,11 @@ const commands = createCommandHandlers({
getAdminReason, getAdminReason,
setAdminReason, setAdminReason,
clearAdminReason, clearAdminReason,
// Room-light lock commands must use the same Home Assistant service instance
// as sockets, HA button triggers, and idle/darkness policies. Passing the
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
getGuildConfig, getGuildConfig,
setGuildConfig, setGuildConfig,
removeGuildConfig, removeGuildConfig,
@@ -3,6 +3,7 @@
// Scope: Exposes stable room-control APIs while delegating internals to focused modules. // Scope: Exposes stable room-control APIs while delegating internals to focused modules.
const logger = require('../../globals/logger').child('homeAssistantService'); const logger = require('../../globals/logger').child('homeAssistantService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { events } = require('./state'); const { events } = require('./state');
const { createRuntimeEngine } = require('./runtimeEngine'); const { createRuntimeEngine } = require('./runtimeEngine');
const { createTransport } = require('./transport'); const { createTransport } = require('./transport');
@@ -10,7 +11,7 @@ const { registerHomeAssistantHooks } = require('./hooks');
const config = loadConfig(); const config = loadConfig();
const haConfig = config.homeAssistant || {}; const haConfig = config.homeAssistant || {};
const enabled = Boolean(haConfig?.url && haConfig?.token); const enabled = isFeatureEnabled('homeAssistant');
let callHomeAssistantServiceImpl = async () => { let callHomeAssistantServiceImpl = async () => {
throw new Error('Home Assistant not connected'); throw new Error('Home Assistant not connected');
@@ -35,18 +36,33 @@ callHomeAssistantServiceImpl = transport.callHomeAssistantService;
runtimeEngine.loadEntityConfig(); runtimeEngine.loadEntityConfig();
runtimeEngine.loadTriggerConfig(); runtimeEngine.loadTriggerConfig();
transport.connect();
registerHomeAssistantHooks({ if (enabled) {
logger, /*
haConfig, Loading the module should be harmless on rover-only installs. Only connect
isLightControlLocked: runtimeEngine.isLightControlLocked, to Home Assistant when the central feature gate says the integration exists,
setLightsLockedOn: runtimeEngine.setLightsLockedOn, so placeholder URLs/tokens in example config cannot start network traffic.
toggleEntity: runtimeEngine.toggleEntity, */
setEntityState: runtimeEngine.setEntityState, transport.connect();
setLightColor: runtimeEngine.setLightColor, }
setLightWhite: runtimeEngine.setLightWhite,
}); 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 = { module.exports = {
getState: runtimeEngine.getState, getState: runtimeEngine.getState,
@@ -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,
};
+40 -28
View File
@@ -5,6 +5,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('liftService'); const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isLockdownAdmin } = require('../roleService');
const { const {
@@ -19,6 +20,7 @@ const events = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
const haConfig = config.homeAssistant || {}; const haConfig = config.homeAssistant || {};
const liftConfig = haConfig.lift || {}; const liftConfig = haConfig.lift || {};
const featureEnabled = isFeatureEnabled('lift');
const upSwitchId = String(liftConfig.upSwitch || '').trim(); const upSwitchId = String(liftConfig.upSwitch || '').trim();
const downSwitchId = String(liftConfig.downSwitch || '').trim(); const downSwitchId = String(liftConfig.downSwitch || '').trim();
@@ -71,7 +73,7 @@ function getState() {
const configured = isConfigured(); const configured = isConfigured();
const connected = isHomeAssistantConnected(); const connected = isHomeAssistantConnected();
return { return {
enabled: Boolean(homeAssistantEnabled && configured), enabled: Boolean(featureEnabled && homeAssistantEnabled && configured),
configured, configured,
connected, connected,
entities: { entities: {
@@ -102,6 +104,7 @@ function emitUpdate() {
} }
function assertReady() { function assertReady() {
if (!featureEnabled) throw new Error('Lift is disabled');
if (!isConfigured()) throw new Error('Lift not configured'); if (!isConfigured()) throw new Error('Lift not configured');
if (!homeAssistantEnabled) throw new Error('Home Assistant not configured'); if (!homeAssistantEnabled) throw new Error('Home Assistant not configured');
if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected'); if (!isHomeAssistantConnected()) throw new Error('Home Assistant not connected');
@@ -173,38 +176,47 @@ async function moveDown(actor = 'unknown') {
return requestPosition('down', actor); return requestPosition('down', actor);
} }
homeAssistantEvents.on('snapshot', emitUpdate); if (featureEnabled) {
homeAssistantEvents.on('status', emitUpdate); /*
Lift state depends on Home Assistant switch snapshots. Subscribe only when
the lift exists so disabled installs do not maintain hardware-specific UI
sync paths.
*/
homeAssistantEvents.on('snapshot', emitUpdate);
homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => { io.on('connection', (socket) => {
socket.on('lift:up', async (_, cb = () => {}) => { socket.on('lift:up', async (_, cb = () => {}) => {
try { try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown'); 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 = () => {}) => { socket.on('lift:down', async (_, cb = () => {}) => {
try { try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown'); 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(); emitUpdate();
+25 -9
View File
@@ -5,6 +5,7 @@ const EventEmitter = require('events');
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('neatoService'); const logger = require('../../globals/logger').child('neatoService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { isVerified } = require('../verificationService'); const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isLockdownAdmin } = require('../roleService');
@@ -20,6 +21,7 @@ const events = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
const haConfig = config.homeAssistant || {}; const haConfig = config.homeAssistant || {};
const neatoConfig = haConfig.neato || {}; const neatoConfig = haConfig.neato || {};
const featureEnabled = isFeatureEnabled('neato');
function normalizeDeviceName(value) { function normalizeDeviceName(value) {
const raw = String(value || '').trim().toLowerCase(); const raw = String(value || '').trim().toLowerCase();
@@ -117,7 +119,7 @@ function buildState() {
const requiredIds = requiredEntityIds(); const requiredIds = requiredEntityIds();
const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id)); const entitiesAvailable = requiredIds.length > 0 && requiredIds.every((id) => isEntityAvailable(id));
const connected = Boolean(haConnected && entitiesAvailable); const connected = Boolean(haConnected && entitiesAvailable);
const enabled = Boolean(homeAssistantEnabled && configured); const enabled = Boolean(featureEnabled && homeAssistantEnabled && configured);
const controls = { const controls = {
start: { start: {
@@ -189,15 +191,25 @@ function emitUpdate() {
} }
} }
homeAssistantEvents.on('snapshot', () => { if (featureEnabled) {
emitUpdate(); /*
}); 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', () => { homeAssistantEvents.on('status', () => {
emitUpdate(); emitUpdate();
}); });
}
function assertConfiguredAndConnected() { function assertConfiguredAndConnected() {
if (!featureEnabled) {
throw new Error('Neato is disabled');
}
if (!device) { if (!device) {
throw new Error('Neato not configured'); throw new Error('Neato not configured');
} }
@@ -255,7 +267,8 @@ function hasVerifiedSockets() {
return false; return false;
} }
io.on('connection', (socket) => { if (featureEnabled) {
io.on('connection', (socket) => {
function assertLockdownAccess() { function assertLockdownAccess() {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown'); throw new Error('Server in lockdown');
@@ -319,7 +332,10 @@ io.on('connection', (socket) => {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
}); });
} else {
logger.info('Neato disabled by config');
}
emitUpdate(); emitUpdate();
@@ -4,6 +4,7 @@
const EventEmitter = require('events'); const EventEmitter = require('events');
const logger = require('../../globals/logger').child('roomCameraService'); const logger = require('../../globals/logger').child('roomCameraService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { getRoomCameraEntries } = require('../../helpers/features');
const events = new EventEmitter(); const events = new EventEmitter();
const config = loadConfig(); const config = loadConfig();
@@ -40,7 +41,7 @@ function getRoomCamera(id) {
function loadFromConfig() { function loadFromConfig() {
cameraMap.clear(); cameraMap.clear();
const list = Array.isArray(config.roomCameras) ? config.roomCameras : []; const list = getRoomCameraEntries(config);
list.forEach((camera) => { list.forEach((camera) => {
const normalized = normalizeCamera(camera); const normalized = normalizeCamera(camera);
if (normalized) cameraMap.set(normalized.id, normalized); if (normalized) cameraMap.set(normalized.id, normalized);
+25 -9
View File
@@ -5,18 +5,34 @@ const { loadFromConfig, getRoomCameras, getRoomCamera, roomCameraEvents } = requ
const { createSnapshotEngine } = require('./snapshotEngine'); const { createSnapshotEngine } = require('./snapshotEngine');
const { registerRoomCameraSocketGateway } = require('./socketGateway'); const { registerRoomCameraSocketGateway } = require('./socketGateway');
const replay = require('../replayEngineV2/roomCameraReplayBuilder'); const replay = require('../replayEngineV2/roomCameraReplayBuilder');
const { isFeatureEnabled } = require('../../helpers/features');
const enabled = isFeatureEnabled('roomCameras');
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents }); 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({ if (enabled) {
getRoomCamera, /*
getRoomCameras, Camera frame sockets are part of the room-camera feature surface. Keeping
getRoomCameraState: snapshotEngine.getRoomCameraState, them behind the same gate prevents disabled features from being callable by
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents, hand even though server/index.js still imports this module.
}); */
registerRoomCameraSocketGateway({
loadFromConfig(); getRoomCamera,
getRoomCameras,
getRoomCameraState: snapshotEngine.getRoomCameraState,
roomCameraStreamEvents: snapshotEngine.roomCameraStreamEvents,
});
}
function buildRoomCameraReplayVideo(options = {}) { function buildRoomCameraReplayVideo(options = {}) {
return replay.buildRoomCameraReplayVideo(options, { getRoomCamera, getRoomCameras }); return replay.buildRoomCameraReplayVideo(options, { getRoomCamera, getRoomCameras });
@@ -26,6 +26,13 @@ const DEFAULT_PRIVATE_SAFETY = Object.freeze({
cliffEnabled: false, cliffEnabled: false,
cliffBackoffSpeed: 250, cliffBackoffSpeed: 250,
cliffBackoffMs: 500, 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, triggerCooldownMs: 800,
}); });
@@ -34,6 +34,7 @@ const {
managerEvents, managerEvents,
backoffTimers, backoffTimers,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
privateButtonStates, privateButtonStates,
privateNoUsersSince, privateNoUsersSince,
privateSafetyTimers, privateSafetyTimers,
@@ -154,6 +155,7 @@ const sensorPipeline = createSensorPipeline({
rovers, rovers,
managerEvents, managerEvents,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
backoffTimers, backoffTimers,
privateButtonStates, privateButtonStates,
privateSafetyTimers, privateSafetyTimers,
@@ -52,6 +52,26 @@ function normalizePrivateSafety(raw = {}) {
5000, 5000,
DEFAULT_PRIVATE_SAFETY.cliffBackoffMs, 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( triggerCooldownMs: clampInt(
source.triggerCooldownMs, source.triggerCooldownMs,
100, 100,
@@ -8,6 +8,7 @@ function createSensorPipeline(deps) {
rovers, rovers,
managerEvents, managerEvents,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
backoffTimers, backoffTimers,
privateButtonStates, privateButtonStates,
privateSafetyTimers, privateSafetyTimers,
@@ -38,6 +39,9 @@ function createSensorPipeline(deps) {
shouldApplyPrivateSensorSafety, shouldApplyPrivateSensorSafety,
} = deps; } = deps;
const DOCK_PROTECTION_MAX_STRIKES = 3;
const DOCK_PROTECTION_STRIKE_RESET_MS = 5 * 60 * 1000;
function getPrivateSafetyState(roverId) { function getPrivateSafetyState(roverId) {
if (!privateSafetyStates.has(roverId)) { if (!privateSafetyStates.has(roverId)) {
privateSafetyStates.set(roverId, { privateSafetyStates.set(roverId, {
@@ -45,6 +49,8 @@ function createSensorPipeline(deps) {
lastOvercurrent: false, lastOvercurrent: false,
lastBump: false, lastBump: false,
lastCliff: false, lastCliff: false,
lastVirtualWall: false,
lastDriveDirection: null,
}); });
} }
return privateSafetyStates.get(roverId); return privateSafetyStates.get(roverId);
@@ -63,6 +69,10 @@ function createSensorPipeline(deps) {
const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs); const cooldownMs = clampInt(options.cooldownMs, 100, 10000, DEFAULT_PRIVATE_SAFETY.triggerCooldownMs);
const backoffMs = clampInt(options.backoffMs, 50, 5000, 0); const backoffMs = clampInt(options.backoffMs, 50, 5000, 0);
const backoffSpeed = clampInt(options.backoffSpeed, 0, 500, 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 { try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } }); issueCommand(roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
issueCommand(roverId, { type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 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 }); logger.warn('Private safety stop failed', { roverId, mode, error: err.message });
} }
stopSafetyBackoffTimer(roverId); 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 speed = Math.max(SAFETY_BACKOFF_MIN, Math.min(SAFETY_BACKOFF_MAX, -Math.abs(backoffSpeed)));
const backoffDrive = explicitBackoffDrive || { left: speed, right: speed };
try { try {
issueCommand(roverId, { type: 'drive', driveDirect: { left: speed, right: speed } }); issueCommand(roverId, { type: 'drive', driveDirect: backoffDrive });
} catch (err) { } catch (err) {
logger.warn('Private safety backoff failed', { roverId, mode, error: err.message }); logger.warn('Private safety backoff failed', { roverId, mode, error: err.message });
} }
@@ -92,16 +106,62 @@ function createSensorPipeline(deps) {
setDriveCooldown(roverId, Math.max(cooldownMs, backoffMs)); setDriveCooldown(roverId, Math.max(cooldownMs, backoffMs));
const state = getPrivateSafetyState(roverId); const state = getPrivateSafetyState(roverId);
state.blockedUntil = now + Math.max(cooldownMs, backoffMs); state.blockedUntil = now + Math.max(cooldownMs, backoffMs);
sendAlert({ if (notify) {
color: ALERT_COLOR, sendAlert({
title: 'Private Safety', color: ALERT_COLOR,
message: `${roverId} ${mode} safety triggered.`, title: 'Private Safety',
}); message: `${roverId} ${mode} safety triggered.`,
publishEvent({ });
source: 'roverManager', publishEvent({
type: 'rover.privateSafetyTriggered', source: 'roverManager',
payload: { roverId, mode, cooldownMs, backoffMs, backoffSpeed }, 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) { function evaluatePrivateSafety(record, sensors) {
@@ -118,22 +178,49 @@ function createSensorPipeline(deps) {
const cliff = Boolean( const cliff = Boolean(
sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight, sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight,
); );
const virtualWall = Boolean(sensors?.virtualWall);
const currentOver = overcurrent; const currentOver = overcurrent;
const currentBump = bump; const currentBump = bump;
const currentCliff = cliff; const currentCliff = cliff;
const currentVirtualWall = virtualWall;
if (!shouldApplyPrivateSensorSafety(record)) { if (!shouldApplyPrivateSensorSafety(record)) {
state.blockedUntil = 0; state.blockedUntil = 0;
state.lastOvercurrent = currentOver; state.lastOvercurrent = currentOver;
state.lastBump = currentBump; state.lastBump = currentBump;
state.lastCliff = currentCliff; state.lastCliff = currentCliff;
state.lastVirtualWall = currentVirtualWall;
return; return;
} }
const safety = getPrivateSafety(record); const safety = getPrivateSafety(record);
const now = Date.now(); 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)) { if (now < Number(state.blockedUntil || 0)) {
state.lastOvercurrent = currentOver; state.lastOvercurrent = currentOver;
state.lastBump = currentBump; state.lastBump = currentBump;
state.lastCliff = currentCliff; state.lastCliff = currentCliff;
state.lastVirtualWall = currentVirtualWall;
return; return;
} }
let triggered = false; let triggered = false;
@@ -163,6 +250,7 @@ function createSensorPipeline(deps) {
state.lastOvercurrent = currentOver; state.lastOvercurrent = currentOver;
state.lastBump = currentBump; state.lastBump = currentBump;
state.lastCliff = currentCliff; state.lastCliff = currentCliff;
state.lastVirtualWall = currentVirtualWall;
} }
function applyPrivateDriveSafety(roverId, socket, driveDirect = null) { function applyPrivateDriveSafety(roverId, socket, driveDirect = null) {
@@ -170,11 +258,20 @@ function createSensorPipeline(deps) {
if (!record || !driveDirect || typeof driveDirect !== 'object') return driveDirect; if (!record || !driveDirect || typeof driveDirect !== 'object') return driveDirect;
if (!shouldApplyPrivateSafety(record, socket)) return driveDirect; if (!shouldApplyPrivateSafety(record, socket)) return driveDirect;
const safety = getPrivateSafety(record); 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 limit = clampInt(safety.speedLimitMaxWheelSpeed, 1, 500, DEFAULT_PRIVATE_SAFETY.speedLimitMaxWheelSpeed);
const left = clampInt(driveDirect.left, -500, 500, 0); const left = clampInt(driveDirect.left, -500, 500, 0);
const right = clampInt(driveDirect.right, -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) { 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) { function handleIdleUndock(undockedRecord) {
if (!undockedRecord || undockedRecord.drivers.size > 0) return; if (!undockedRecord || undockedRecord.drivers.size > 0) return;
const now = Date.now(); const now = Date.now();
@@ -342,10 +501,11 @@ function createSensorPipeline(deps) {
const suspectRecord = rovers.get(suspect.roverId); const suspectRecord = rovers.get(suspect.roverId);
if (!suspectRecord) return; if (!suspectRecord) return;
const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS; const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
const strikes = recordDockProtectionStrike(suspect);
sendAlert({ sendAlert({
color: ALERT_COLOR, color: ALERT_COLOR,
title: 'Dock protection', 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 { try {
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } }); issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
@@ -356,6 +516,13 @@ function createSensorPipeline(deps) {
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS); setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
if (bumpRecent) nudgeRover(suspect.roverId, 'backward'); if (bumpRecent) nudgeRover(suspect.roverId, 'backward');
else nudgeRover(suspect.roverId, 'forward'); 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) { function handleSensorFrame(roverId, frame) {
@@ -9,6 +9,7 @@ const spectatorSockets = new Set();
const managerEvents = new EventEmitter(); const managerEvents = new EventEmitter();
const backoffTimers = new Map(); const backoffTimers = new Map();
const dockGuardStates = new Map(); const dockGuardStates = new Map();
const dockProtectionStrikeStates = new Map();
const privateButtonStates = new Map(); const privateButtonStates = new Map();
const privateNoUsersSince = new Map(); const privateNoUsersSince = new Map();
const privateSafetyTimers = new Map(); const privateSafetyTimers = new Map();
@@ -21,6 +22,7 @@ module.exports = {
managerEvents, managerEvents,
backoffTimers, backoffTimers,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
privateButtonStates, privateButtonStates,
privateNoUsersSince, privateNoUsersSince,
privateSafetyTimers, privateSafetyTimers,
@@ -2,12 +2,13 @@
// Purpose: Defines timing and static social/config constants used by session synchronization behavior. // Purpose: Defines timing and static social/config constants used by session synchronization behavior.
// Scope: Keeps runtime behavior unchanged while isolating constants from orchestration logic. // Scope: Keeps runtime behavior unchanged while isolating constants from orchestration logic.
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { getConfiguredSocials } = require('../../helpers/features');
const config = loadConfig(); const config = loadConfig();
const discordInvite = config.discord?.invite || null; const discordInvite = config.discord?.invite || null;
const kofiLink = config.kofi?.link || null; const kofiLink = config.kofi?.link || null;
const serverTimezone = config.timezone || 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 ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000; const GPIO_TOGGLE_SYNC_COOLDOWN_MS = 1000;
+20 -7
View File
@@ -32,9 +32,11 @@ const { getGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason } = require('../adminReasonService'); const { getAdminReason } = require('../adminReasonService');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver'); const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const { getFeatureFlags } = require('../../helpers/features');
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService'); const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService'); const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
const { getButtonBoxState } = require('../buttonBoxService'); const { getButtonBoxState } = require('../buttonBoxService');
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
const { const {
discordInvite, discordInvite,
kofiLink, kofiLink,
@@ -70,6 +72,7 @@ function buildUserEntry(socket) {
function buildSession(socket) { function buildSession(socket) {
const overseerVote = getOverseerVoteStatus(); const overseerVote = getOverseerVoteStatus();
const features = getFeatureFlags();
const users = Array.from(io.sockets.sockets.values()) const users = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock)) .map((sock) => buildUserEntry(sock))
.filter(Boolean) .filter(Boolean)
@@ -82,18 +85,18 @@ function buildSession(socket) {
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId); const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket); const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket); const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
const socials = const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
configuredSocials?.length
? configuredSocials
: [
...(discordInvite ? [{ id: 'discord', label: 'Discord', url: discordInvite }] : []),
...(kofiLink ? [{ id: 'kofi', label: 'Ko-fi', url: kofiLink }] : []),
];
return { return {
socketId: socket?.id || null, socketId: socket?.id || null,
role: getRole(socket), role: getRole(socket),
mode: getMode(), mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)), 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, roster,
odometers: roverManager.getOdometersForSocket(socket), odometers: roverManager.getOdometersForSocket(socket),
assignment: { assignment: {
@@ -130,6 +133,12 @@ function buildSession(socket) {
audioForward: getAudioForwardState(), audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(), audioLevels: getAudioLevels(),
buttonBox: getButtonBoxState(), 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: {
...overseerVote, ...overseerVote,
preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true, preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true,
@@ -345,6 +354,10 @@ audioLevelsEvents.on('change', () => {
syncAll(); syncAll();
}); });
interInstanceEvents.on('change', () => {
syncAll();
});
// sync all sockets 20 seconds // sync all sockets 20 seconds
// setInterval(() => { // setInterval(() => {
// logger.info('Periodic session sync for all clients'); // logger.info('Periodic session sync for all clients');
+10 -1
View File
@@ -11,9 +11,18 @@ function stopRover(roverId) {
} }
} }
function removeDriverCompletely(roverId, socketId) { function removeDriverCompletely(roverId, socketId, notice = null) {
try { try {
const assignmentService = require('../assignmentService'); 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); assignmentService.forceRelease(roverId, socketId);
} catch (err) { } catch (err) {
// best effort; log elsewhere if needed // best effort; log elsewhere if needed
+6 -1
View File
@@ -290,12 +290,17 @@ function handleIdleTimeout(roverId, expectedDriver) {
const skips = incrementSkip(roverId, expectedDriver); const skips = incrementSkip(roverId, expectedDriver);
stopRover(roverId); stopRover(roverId);
if (skips >= MAX_IDLE_SKIPS) { 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({ sendAlert({
color: ALERT_COLOR, color: ALERT_COLOR,
title: 'Driver removed', title: 'Driver removed',
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`, 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; return;
} }
sendAlert({ sendAlert({
+3 -6
View File
@@ -1,10 +1,7 @@
1. improve spectator page, options on what to see and what not to see 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 2. synthesize wheel speed sensors based on encoder readings server side, add them to json sensor data, show them in topdown
3. admin command for kicking people off of rovers. just a kick, nothing persistent 3. add more background gap themes
4. kick people off rover after 3 consecurtive bump-off attempts 4. fix this:
5. add rs admin command for controlling light lock
6. make signaling more clear for idle skips and idle skip kicks
7. 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]: /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]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^ Jun 18 15:14:18 roombaserver.local node[216731]: ^
+8 -2
View File
@@ -35,6 +35,7 @@ import SettingsPanel from './components/SettingsPanel/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx'; import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
import useDefaultNickname from './hooks/useDefaultNickname.js'; import useDefaultNickname from './hooks/useDefaultNickname.js';
import useUserIdentitySync from './hooks/useUserIdentitySync.js'; import useUserIdentitySync from './hooks/useUserIdentitySync.js';
import useIncomingInterInstanceTransfer from './hooks/useIncomingInterInstanceTransfer.js';
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx'; import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx'; import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
import VipPanel from './components/VipPanel/index.jsx'; import VipPanel from './components/VipPanel/index.jsx';
@@ -250,7 +251,9 @@ function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = fa
</section> </section>
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}> <div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" /> <ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
<RoverQueuesPanel /> <div className="space-y-0.5">
<RoverQueuesPanel />
</div>
</div> </div>
{/* <ControlSummary /> */} {/* <ControlSummary /> */}
<MobileFeatureTabs <MobileFeatureTabs
@@ -278,7 +281,9 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
<DriverVideo layoutFormat="mobile-landscape" /> <DriverVideo layoutFormat="mobile-landscape" />
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}> <div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]`}>
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" /> <ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<RoverQueuesPanel /> <div className="space-y-0.5">
<RoverQueuesPanel />
</div>
</div> </div>
{/* <TelemetryPanel /> */} {/* <TelemetryPanel /> */}
</div> </div>
@@ -309,6 +314,7 @@ function App() {
function AppWithProviders({ layout, isDesktop, fullscreen }) { function AppWithProviders({ layout, isDesktop, fullscreen }) {
useDefaultNickname(); useDefaultNickname();
useIncomingInterInstanceTransfer();
useUserIdentitySync({ identitySurface: 'driver' }); useUserIdentitySync({ identitySurface: 'driver' });
useTelemetryVisualPolicy({ mobile: !isDesktop }); useTelemetryVisualPolicy({ mobile: !isDesktop });
const { const {
@@ -18,6 +18,47 @@ const MODES = [
{ key: 'lockdown', label: 'Lockdown' }, { 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() { export default function AdminPanelContent() {
const { const {
session, session,
@@ -58,6 +99,7 @@ export default function AdminPanelContent() {
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1, forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
}); });
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({}); const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
const isAdmin = const isAdmin =
session?.role === 'admin' || session?.role === 'admin' ||
@@ -257,39 +299,21 @@ export default function AdminPanelContent() {
useEffect(() => { useEffect(() => {
const next = {}; setPrivateSafetyDrafts((currentDrafts) => {
(roster || []).forEach((rover) => { const next = {};
if (!rover?.private?.enabled) return; (roster || []).forEach((rover) => {
next[rover.id] = { if (!rover?.private?.enabled) return;
speedLimitEnabled: Boolean(rover?.private?.safety?.speedLimitEnabled), // Session syncs can arrive while an admin is focused in one of these
speedLimitMaxWheelSpeed: Number.isFinite(rover?.private?.safety?.speedLimitMaxWheelSpeed) // inputs. Preserve that rover's local draft once touched; otherwise the
? rover.private.safety.speedLimitMaxWheelSpeed // server roster remains the source of truth for newly connected rovers
: 250, // and for rovers whose form is not being edited.
hardOvercurrentEnabled: Boolean(rover?.private?.safety?.hardOvercurrentEnabled), next[rover.id] = privateSafetyDirty?.[rover.id]
overcurrentStopMs: Number.isFinite(rover?.private?.safety?.overcurrentStopMs) ? currentDrafts?.[rover.id] || buildPrivateSafetyDraft(rover)
? rover.private.safety.overcurrentStopMs : buildPrivateSafetyDraft(rover);
: 300, });
hardBumpEnabled: Boolean(rover?.private?.safety?.hardBumpEnabled), return next;
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(next); }, [privateSafetyDirty, roster]);
}, [roster]);
const lockMap = useMemo(() => { const lockMap = useMemo(() => {
const map = {}; const map = {};
@@ -304,13 +328,25 @@ export default function AdminPanelContent() {
...(current || {}), ...(current || {}),
[roverId]: { ...(current?.[roverId] || {}), ...(patch || {}) }, [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) => { const handlePrivateSafetySave = async (roverId) => {
try { try {
const draft = privateSafetyDrafts?.[roverId]; const draft = privateSafetyDrafts?.[roverId];
if (!draft) return; 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) { } catch (err) {
alert(err.message); alert(err.message);
} }
@@ -536,6 +572,16 @@ export default function AdminPanelContent() {
/> />
<span>Cliff safety</span> <span>Cliff safety</span>
</label> </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 <button
type="button" type="button"
disabled={!isLockdownAdmin} disabled={!isLockdownAdmin}
@@ -5,6 +5,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js'; import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js'; import { trackAnalyticsEvent } from '../../analytics/index.js';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
function clampRgbChannel(value) { function clampRgbChannel(value) {
if (!Number.isFinite(value)) return null; if (!Number.isFinite(value)) return null;
@@ -247,6 +249,18 @@ function Leaderboard({ players, ownPlayer }) {
); );
} }
export default function BarcodeGamesPanel() { 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 { state, connectionState, voteForGame } = useBarcodeGameState();
const [pendingGameId, setPendingGameId] = useState(null); const [pendingGameId, setPendingGameId] = useState(null);
const activeSignatureRef = useRef(''); const activeSignatureRef = useRef('');
@@ -8,6 +8,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx'; import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
const FLASH_MS = 420; const FLASH_MS = 420;
const REWARD_FLASH_MS = 1200; const REWARD_FLASH_MS = 1200;
@@ -20,6 +21,18 @@ const BUTTON_TONES = {
}; };
export default function ButtonBoxPanel() { 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 buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const socket = useSocket(); const socket = useSocket();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
+41 -12
View File
@@ -11,6 +11,46 @@ import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx'; import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx'; import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
import CardFrame from '../CardFrame/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' }) { export default function DriverVideo({ layoutFormat = 'desktop' }) {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); 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); const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
if (!roverId) { if (!roverId) {
return ( return <EmptyDriverVideoNotice />;
<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>
);
} }
const mobileHud = layoutFormat !== 'desktop'; const mobileHud = layoutFormat !== 'desktop';
@@ -6,6 +6,7 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
import { useControlSelector } from '../../controls/index.js'; import { useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
const COLOR_SWATCHES = Object.freeze([ const COLOR_SWATCHES = Object.freeze([
{ id: 'white', label: 'White', hex: '#ffffff', action: 'white' }, { id: 'white', label: 'White', hex: '#ffffff', action: 'white' },
@@ -169,6 +170,19 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
} }
export default function HomeAssistantControls() { 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 keymap = useControlSelector((control) => control.state.keymap);
const ha = useSessionSelector((state) => state.session?.homeAssistant || null); const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } = const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
@@ -195,9 +195,12 @@ function TurnsOverlay({
Video switched to preview mode to save bandwidth. Video switched to preview mode to save bandwidth.
</div> </div>
) : null} ) : null}
<div className="pointer-events-auto mt-0.5"> <SocialButton
<SocialButton id="discord" label="Join our Discord while you wait!" layout='inline'/> id="discord"
</div> label="Join our Discord while you wait!"
layout="inline"
className="pointer-events-auto mt-0.5"
/>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -0,0 +1,267 @@
// 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 { openExternalRoverWithPrompt } from '../../lib/interInstanceTransfer.js';
import { isFeatureEnabled } from '../../lib/features.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 in lockdown', tone: 'red' };
if (mode === 'admin') return { blocked: true, label: 'Admin only', overlay: 'This server is admin only', 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);
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={() => openExternalRoverWithPrompt(remote, '')}>
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, normalizeBinaryPayload,
normalizeKinectStatus, normalizeKinectStatus,
} from './utils.js'; } from './utils.js';
import { isFeatureEnabled } from '../../lib/features.js';
export default function KinectPanel() { 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 socket = useSocket();
const status = useSessionSelector((state) => normalizeKinectStatus(state.session?.kinect)); const status = useSessionSelector((state) => normalizeKinectStatus(state.session?.kinect));
const [activeView, setActiveView] = useState('3d'); const [activeView, setActiveView] = useState('3d');
+13
View File
@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
function badgeClass(tone) { function badgeClass(tone) {
if (tone === 'good') return 'bg-emerald-600 text-white'; if (tone === 'good') return 'bg-emerald-600 text-white';
@@ -21,6 +22,18 @@ function positionLabel(value) {
} }
export default function LiftCard() { 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 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 lift state and socket actions directly. Keeping that wiring inside the card
+43 -28
View File
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js'; import { useSharedClock } from '../../hooks/useSharedClock.js';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
import ChatPanel from '../ChatPanel/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 PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
const LOCKDOWN_ROLES = new Set(['lockdown']); const LOCKDOWN_ROLES = new Set(['lockdown']);
@@ -32,6 +34,7 @@ export default function ModeGateOverlay() {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const reason = useSessionSelector((state) => state.session?.adminReason?.text || ''); const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC'); const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const restricted = RESTRICTED_MODES.has(mode); const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role); const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
/* /*
@@ -62,35 +65,47 @@ export default function ModeGateOverlay() {
const details = getModeDetails(mode); const details = getModeDetails(mode);
return ( 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="pointer-events-auto fixed inset-0 z-50 overflow-y-auto 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="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="space-y-0.5"> <div className="surface w-full max-w-md shrink-0 space-y-0.5 text-slate-100 shadow-2xl">
<p className="text-lg font-semibold">{details.title}</p> <div className="space-y-0.5">
<p className="text-sm text-slate-300">{details.description}</p> <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>
<div className="surface-muted space-y-0.5"> {interInstanceEnabled ? (
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p> /*
<p className="text-lg font-semibold text-slate-100"> The external browser is a sibling of the login card, not content
{reason ? reason : 'No reason set.'} inside it. hideWhenEmpty lets the login card remain centered when
</p> the directory has no other servers to offer.
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p> */
</div> <InterInstanceBrowserFrame
<div className="surface-muted"> hideWhenEmpty
<AuthPanel /> className="max-w-[calc(100vw-0.5rem)]"
</div> bodyClassName="max-h-[86vh] overflow-y-auto p-0.5"
<div className="w-full justify-center items-center"> />
<SocialButton id="discord" label="Join our Discord server for updates!"/> ) : null}
</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> */}
</div> </div>
</div> </div>
); );
+13
View File
@@ -4,6 +4,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
function normalizeState(value) { function normalizeState(value) {
return String(value || '').trim(); return String(value || '').trim();
@@ -41,6 +42,18 @@ function StatusTile({ label, value, tone = 'muted', valueClass = '', hideLabel =
} }
export default function NeatoCard() { export default function NeatoCard() {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'neato'));
/*
Neato support is optional hardware surfaced through Home Assistant. Keeping
the feature gate in this card avoids scattered checks in each route layout.
*/
if (!enabled) return null;
return <NeatoCardContent />;
}
function NeatoCardContent() {
/* /*
Neato is a standalone public activity card. It owns its session selector and Neato is a standalone public activity card. It owns its session selector and
command actions so callers do not need to know the socket event names or command actions so callers do not need to know the socket event names or
@@ -4,6 +4,8 @@ import { formatKeyLabel } from '../../controls/keymapUtils.js';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { getSocialById } from '../../lib/socials.js';
function ControlRow({ label, keyLabel }) { function ControlRow({ label, keyLabel }) {
return ( return (
@@ -45,6 +47,25 @@ function MobileQuickstart() {
); );
} }
function DiscordQuickstartCard() {
const discord = useSessionSelector((state) => getSocialById(state, 'discord'));
/*
The section has explanatory text around the actual button, so SocialButton's
own null return is not enough here. Keep the whole Discord prompt self-
contained so layouts do not need a separate social feature check.
*/
if (!discord?.url) return null;
return (
<div className="surface p-0.5">
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
<SocialButton id="discord" label="Join Discord" />
</div>
);
}
export default function QuickstartOverlay({ export default function QuickstartOverlay({
visible, visible,
layout, layout,
@@ -85,11 +106,7 @@ export default function QuickstartOverlay({
<p className="text-sm font-semibold text-slate-200">Nicknames are assigned randomly by default, you can change yours here.</p> <p className="text-sm font-semibold text-slate-200">Nicknames are assigned randomly by default, you can change yours here.</p>
<NicknameForm compact /> <NicknameForm compact />
</div> </div>
<div className="surface p-0.5"> <DiscordQuickstartCard />
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
<SocialButton id="discord" label="Join Discord" />
</div>
</section> </section>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-0.5 border-t border-slate-700 px-0.5 py-0.35 text-[0.8rem]"> <div className="flex flex-wrap items-center justify-between gap-0.5 border-t border-slate-700 px-0.5 py-0.35 text-[0.8rem]">
+20 -5
View File
@@ -187,6 +187,25 @@ function DriveDockPanel() {
); );
} }
function QueueReplayLinksRow() {
/*
Flex ratios match the old grid proportions when the Links panel exists. If
LinkButtonsPanel returns null because socials are disabled, flex naturally
removes that item instead of preserving an empty grid column.
*/
return (
<div className={`flex ${themeGapClass}`}>
<div className={`min-w-0 basis-0 grow-[1] space-y-0.5`}>
<RoverQueuesPanel />
</div>
<div className="min-w-0 basis-0 grow-[0.9]">
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
</div>
<LinkButtonsPanel className="min-w-0 basis-0 grow-[0.75]" />
</div>
);
}
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) { export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const [activeTab, setActiveTab] = useState('telemetry'); const [activeTab, setActiveTab] = useState('telemetry');
const chatDockRef = useRef(null); const chatDockRef = useRef(null);
@@ -354,11 +373,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<TopDownMapPanel /> <TopDownMapPanel />
<DriveDockPanel /> <DriveDockPanel />
</div> </div>
<div className={`grid ${themeGapClass} grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,0.75fr)]`}> <QueueReplayLinksRow />
<RoverQueuesPanel />
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
<LinkButtonsPanel />
</div>
{/* {/*
This row gets an explicit measured height because the target This row gets an explicit measured height because the target
behavior depends on the row's live viewport position during behavior depends on the row's live viewport position during
+14 -1
View File
@@ -7,6 +7,7 @@ import { useSettingsNamespace } from '../../settings/index.js';
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js'; import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
import RoomCameraFeed from '../RoomCameraFeed/index.jsx'; import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
function EmptyState() { function EmptyState() {
return ( return (
@@ -103,7 +104,19 @@ function useCameraPanelSubscriptionGate() {
return { panelRef, isPanelVisible }; return { panelRef, isPanelVisible };
} }
export default function RoomCameraPanel({ export default function RoomCameraPanel(props) {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'roomCameras'));
/*
Room-camera visibility belongs with the room-camera panel. This keeps every
route free to mount the panel without duplicating the server feature rule.
*/
if (!enabled) return null;
return <RoomCameraPanelContent {...props} />;
}
function RoomCameraPanelContent({
defaultOrientation = 'horizontal', defaultOrientation = 'horizontal',
orientation: forcedOrientation, orientation: forcedOrientation,
hideLayoutToggle = false, hideLayoutToggle = false,
+63 -16
View File
@@ -7,6 +7,9 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import RoverLabel from '../RoverLabel/index.jsx'; import RoverLabel from '../RoverLabel/index.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js'; import { trackAnalyticsEvent } from '../../analytics/index.js';
import { openExternalRoverWithPrompt } from '../../lib/interInstanceTransfer.js';
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
function classNames(...values) { function classNames(...values) {
return values.filter(Boolean).join(' '); return values.filter(Boolean).join(' ');
@@ -46,11 +49,19 @@ function formatLabel(user, selfId) {
return base; return base;
} }
export default function RoverQueuesPanel({ title = 'Rovers' }) { export default function RoverQueuesPanel({
title = 'Rovers',
roster: rosterOverride = null,
turnQueues: turnQueuesOverride = null,
users: usersOverride = null,
externalInstance = null,
disabledOverlay = '',
}) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {}); const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const users = useSessionSelector((state) => state.session?.users ?? []); const localUsers = useSessionSelector((state) => state.session?.users ?? []);
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim()); const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const assignedRoverName = useSessionSelector((state) => { const assignedRoverName = useSessionSelector((state) => {
@@ -62,8 +73,16 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const { requestControl, rebootOwnRover } = useSessionActions(); const { requestControl, rebootOwnRover } = useSessionActions();
const [pending, setPending] = useState({}); const [pending, setPending] = useState({});
const [rebootPending, setRebootPending] = useState(false); const [rebootPending, setRebootPending] = useState(false);
const externalMode = Boolean(externalInstance);
const externalBlocked = Boolean(externalMode && disabledOverlay);
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues;
const users = Array.isArray(usersOverride) ? usersOverride : localUsers;
const canRequest = useMemo(() => role && role !== 'spectator', [role]); const canRequest = useMemo(
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
[externalBlocked, externalMode, role],
);
const adminCapable = useMemo( const adminCapable = useMemo(
() => role === 'admin' || role === 'lockdown', () => role === 'admin' || role === 'lockdown',
[role], [role],
@@ -89,6 +108,16 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
async function handleRequest(targetRoverId) { async function handleRequest(targetRoverId) {
if (!targetRoverId) return; if (!targetRoverId) return;
if (externalBlocked) return;
if (externalMode) {
/*
External queue cards deliberately reuse the local row layout, but their
action cannot go through this Socket.IO server. The row opens the remote
instance, optionally carrying settings after the source-page prompt.
*/
openExternalRoverWithPrompt(externalInstance, targetRoverId);
return;
}
setPending((prev) => ({ ...prev, [targetRoverId]: true })); setPending((prev) => ({ ...prev, [targetRoverId]: true }));
trackAnalyticsEvent('rover_queue_join', { trackAnalyticsEvent('rover_queue_join', {
roverId: targetRoverId, roverId: targetRoverId,
@@ -139,7 +168,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
} }
const headerActions = const headerActions =
role !== 'spectator' && assignedRoverId ? ( !externalMode && role !== 'spectator' && assignedRoverId ? (
<button <button
type="button" type="button"
onClick={handleRebootOwnRover} onClick={handleRebootOwnRover}
@@ -153,11 +182,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
return ( return (
<CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm"> <CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm">
{rosterItems.length === 0 ? ( <div className="relative space-y-0.5">
<p className="text-sm text-slate-500">No rovers registered.</p> {rosterItems.length === 0 ? (
) : ( <p className="text-sm text-slate-500">No rovers registered.</p>
<ul className="space-y-0.5 text-sm"> ) : (
{rosterItems.map((rover) => { <ul className="space-y-0.5 text-sm">
{rosterItems.map((rover) => {
const roverId = String(rover.id); const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null; const info = turnQueues?.[roverId] || null;
const queue = info?.queue || []; const queue = info?.queue || [];
@@ -178,12 +208,14 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open); const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open); const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
const locked = Boolean(rover.locked); const locked = Boolean(rover.locked);
const lockedBlocked = locked && !adminCapable && !isGrantedClosedPrivate; const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked'; const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
const buttonLabel = pending[roverId] const buttonLabel = pending[roverId]
? '...' ? '...'
: locked && !isGrantedClosedPrivate : lockedBlocked
? lockLabel ? lockLabel
: externalMode
? 'Open'
: 'request'; : 'request';
const canClickRow = canRequest && !lockedBlocked && !pending[roverId]; const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
return ( return (
@@ -203,6 +235,14 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
handleRequest(rover.id); handleRequest(rover.id);
}} }}
> >
{externalMode && rover?.snapshots?.latestUrl ? (
<img
src={rover.snapshots.latestUrl}
alt=""
className="h-8 w-10 shrink-0 rounded border border-slate-700 bg-black object-cover"
loading="lazy"
/>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-0.5"> <div className="flex items-center justify-between gap-0.5">
<div className="flex min-w-0 items-center gap-0.5"> <div className="flex min-w-0 items-center gap-0.5">
@@ -271,9 +311,16 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
) : null} ) : null}
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
{externalBlocked ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
{disabledOverlay}
</div>
) : null}
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null}
</div>
</CardFrame> </CardFrame>
); );
} }
@@ -2,12 +2,19 @@
// Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file. // Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
export default function SocialButtonsGrid({ className = '' }) { export default function SocialButtonsGrid({ className = '' }) {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4); const socials = useSessionSelector((state) => state.session?.socials ?? []).slice(0, 4);
if (!socials.length) return null; /*
The Links panel owns its feature visibility. Even if the server config has
link entries, `socials.enabled: false` makes the server advertise the
feature as disabled, and this component disappears completely.
*/
if (!enabled || !socials.length) return null;
return ( return (
<div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}> <div className={`grid grid-cols-2 grid-rows-2 gap-0.5 ${className}`}>
+17 -2
View File
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState, useCallback } from 'react'; import { useEffect, useMemo, useState, useCallback } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx'; import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
@@ -18,9 +19,23 @@ export function NicknameEntryPanel({ compact = false }) {
); );
} }
export function LinkButtonsPanel() { export function LinkButtonsPanel({ className = '' }) {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
/*
This component is the actual Links panel shell. SocialButtonsGrid already
hides the buttons when socials are disabled, but the shell must also hide
itself so the UI does not leave an empty "Links!" card behind.
*/
if (!enabled) return null;
return ( return (
<CardFrame title="Links!" fillHeight bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base"> <CardFrame
title="Links!"
fillHeight
className={className}
bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base"
>
<SocialButtonsGrid className="flex-1 min-h-0" /> <SocialButtonsGrid className="flex-1 min-h-0" />
</CardFrame> </CardFrame>
); );
+28
View File
@@ -19,6 +19,7 @@ const INITIAL_STATE = {
latestReplay: null, latestReplay: null,
latestRequestedReplay: null, latestRequestedReplay: null,
duplicateIdentityBlock: null, duplicateIdentityBlock: null,
roverRemovalNotice: null,
}; };
const SessionContext = createContext(null); const SessionContext = createContext(null);
@@ -304,6 +305,31 @@ export function SessionProvider({ children }) {
}, },
})); }));
} }
function handleRoverRemovalNotice(payload = {}) {
/*
Removal reasons arrive as socket events because the next normal session
sync only says "not assigned". Keeping the explanation outside the
session tree lets the no-rover video panel tell the user why control was
removed after admin, safety, or idle-removal actions.
*/
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message.trim()
: 'You were removed from the rover.';
const title =
typeof payload?.title === 'string' && payload.title.trim()
? payload.title.trim()
: 'Removed from rover';
setState((prev) => ({
...prev,
roverRemovalNotice: {
...payload,
title,
message,
receivedAt: Date.now(),
},
}));
}
socket.on('session:sync', handleSession); socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit); socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry); socket.on('log:entry', handleLogEntry);
@@ -317,6 +343,7 @@ export function SessionProvider({ children }) {
socket.on('replay:ready', handleReplayReady); socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed); socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity); socket.on('session:duplicateIdentity', handleDuplicateIdentity);
socket.on('session:roverRemovalNotice', handleRoverRemovalNotice);
return () => { return () => {
socket.off('session:sync', handleSession); socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit); socket.off('log:init', handleLogInit);
@@ -331,6 +358,7 @@ export function SessionProvider({ children }) {
socket.off('replay:ready', handleReplayReady); socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed); socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity); socket.off('session:duplicateIdentity', handleDuplicateIdentity);
socket.off('session:roverRemovalNotice', handleRoverRemovalNotice);
}; };
}, [setState, socket]); }, [setState, socket]);
@@ -0,0 +1,60 @@
// Hook: useIncomingInterInstanceTransfer
// Purpose: Applies settings transferred through an inter-instance URL before the normal identity heartbeat runs.
// Scope: Owns only inbound URL parameters; requesting the target rover is handled after socket/session state is ready.
import { useEffect, useRef } from 'react';
import { useSettings } from '../settings/index.js';
import { base64UrlDecodeJson } from '../lib/interInstanceTransfer.js';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
export default function useIncomingInterInstanceTransfer() {
const settings = useSettings();
const { requestControl } = useSessionActions();
const connected = useSessionSelector((state) => state.connected);
const appliedRef = useRef(false);
const requestedRef = useRef(false);
const roverIdRef = useRef('');
useEffect(() => {
if (appliedRef.current || typeof window === 'undefined') return;
const url = new URL(window.location.href);
const transfer = url.searchParams.get('settingsTransfer');
roverIdRef.current = String(url.searchParams.get('rover') || '').trim();
if (!transfer) {
appliedRef.current = true;
return;
}
try {
/*
The source page already asked before adding settingsTransfer. A present
transfer param is therefore an explicit instruction to replace the local
settings cookie without asking again on the destination server.
*/
const nextSettings = base64UrlDecodeJson(transfer);
settings.saveAll(nextSettings && typeof nextSettings === 'object' ? nextSettings : {});
url.searchParams.delete('settingsTransfer');
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
} catch (error) {
// A bad transfer payload should not block the page or the rover request.
console.warn('Failed to apply transferred inter-instance settings', error);
} finally {
appliedRef.current = true;
}
}, [settings]);
useEffect(() => {
if (!appliedRef.current || requestedRef.current || !connected) return;
const roverId = roverIdRef.current;
if (!roverId) return;
requestedRef.current = true;
/*
The identity heartbeat reacts to the settings overwrite through the shared
settings context. Waiting for a connected socket here keeps this hook from
racing the initial Socket.IO connection while still using the existing
request-control path.
*/
requestControl(roverId).catch((error) => {
requestedRef.current = false;
console.warn('Failed to request transferred inter-instance rover', error);
});
}, [connected, requestControl]);
}
+15
View File
@@ -0,0 +1,15 @@
// Feature Helpers
// Purpose: Centralizes client-side reads of server-advertised optional features.
// Scope: Keeps layout/components from each inventing their own "is this feature configured?" rule.
export function isFeatureEnabled(state, featureName) {
/*
The server owns feature detection because only it can reliably know whether
config-driven hardware integrations exist. React should treat missing flags
as disabled so old or partial session payloads fail closed and hide extras.
*/
return Boolean(state?.session?.features?.[featureName]);
}
export function anyFeatureEnabled(state, featureNames = []) {
return featureNames.some((featureName) => isFeatureEnabled(state, featureName));
}
+48
View File
@@ -0,0 +1,48 @@
// Inter-Instance Transfer Helpers
// Purpose: Builds cross-server links and moves the local settings cookie only when the user opts in before leaving.
// Scope: Keeps URL encoding and settings-transfer behavior out of the rover queue rendering code.
import { loadSettings } from '../settings/persistence.js';
function base64UrlEncodeJson(value) {
const json = JSON.stringify(value ?? {});
const bytes = new TextEncoder().encode(json);
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64UrlDecodeJson(value) {
const raw = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = raw.padEnd(Math.ceil(raw.length / 4) * 4, '=');
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
export function buildExternalRoverUrl(instance, roverId, { includeSettings = false } = {}) {
const publicUrl = String(instance?.instance?.publicUrl || instance?.publicUrl || instance?.url || '').trim();
if (!publicUrl) return '';
const url = new URL(publicUrl);
if (roverId) url.searchParams.set('rover', String(roverId));
/*
The destination always applies settingsTransfer if present, so this helper
only adds it after the current page has already asked for consent.
*/
if (includeSettings) {
url.searchParams.set('settingsTransfer', base64UrlEncodeJson(loadSettings()));
}
return url.toString();
}
export function openExternalRoverWithPrompt(instance, roverId) {
const withoutTransfer = buildExternalRoverUrl(instance, roverId);
if (!withoutTransfer) return;
const instanceName = String(instance?.instance?.name || instance?.url || 'that server');
const includeSettings = window.confirm(
`Transfer your identity and settings to ${instanceName}? Press Cancel to open without transferring them.`,
);
const targetUrl = buildExternalRoverUrl(instance, roverId, { includeSettings });
window.location.href = targetUrl || withoutTransfer;
}
+2 -2
View File
@@ -2,9 +2,9 @@
// Purpose: Defines namespace identifiers used to segment persisted settings data. Scope: Prevents key collisions and standardizes settings lookup domains. // Purpose: Defines namespace identifiers used to segment persisted settings data. Scope: Prevents key collisions and standardizes settings lookup domains.
export const INPUT_SETTINGS_DEFAULTS = { export const INPUT_SETTINGS_DEFAULTS = {
keyboard: { keyboard: {
baseSpeed: 250, baseSpeed: 210,
turboSpeed: 500, turboSpeed: 500,
precisionSpeed: 125, precisionSpeed: 100,
tiltSpeed: 90, tiltSpeed: 90,
tiltIntervalMs: 110, tiltIntervalMs: 110,
}, },
@@ -5,15 +5,11 @@ import RoomCameraPanel from '../../../components/RoomCameraPanel/index.jsx';
export default function SecondaryRow() { export default function SecondaryRow() {
return ( return (
<section className="min-h-0"> <RoomCameraPanel
<div className="surface min-h-[14rem] overflow-hidden"> defaultOrientation="horizontal"
<RoomCameraPanel hideLayoutToggle
defaultOrientation="horizontal" hideHeader
hideLayoutToggle panelId="spectator-secondary"
hideHeader />
panelId="spectator-secondary"
/>
</div>
</section>
); );
} }