Compare commits

..
Author SHA1 Message Date
legop3 dd1fd1e167 forgot to build 2026-07-19 20:54:46 -04:00
legop3 eba4b1dc1d add realtime upload/download host stats 2026-07-19 20:52:50 -04:00
legop3 cacd125fcb chat history and other stuffs, light commands replay fixes 2026-07-18 13:10:08 -04:00
legop3 8a4162683f snapshot fixes 2026-07-18 03:22:01 -04:00
legop3 387a5f47d7 oooopes 2026-07-18 00:13:04 -04:00
legop3 5b6947d92c wording adjust :3 :3 :3 2026-07-18 00:12:47 -04:00
legop3 cd8f8816c9 interinstance ui improvements 2026-07-18 00:09:51 -04:00
legop3 b86eec88f8 trying to fix zoom stopping camera 2026-07-17 23:33:46 -04:00
legop3 512adfc1e0 wawa 2026-07-17 23:25:03 -04:00
legop3 fe33f27bd0 bwah 2026-07-17 23:20:44 -04:00
legop3 afe8ffc62e ptz control updates 2026-07-17 23:06:12 -04:00
legop3 4e6e9e3021 new themeses!! 2026-07-17 21:01:48 -04:00
legop3 f9461433af better um interinstance ui um stuff yeah lole 2026-07-17 17:38:04 -04:00
legop3 b7d421c489 slop 2026-07-17 15:13:25 -04:00
legop3 c6b2843150 slop 2026-07-17 15:04:37 -04:00
legop3 b451849c02 slop 2026-07-17 15:04:31 -04:00
legop3 955f6f213d slop 2026-07-17 14:38:47 -04:00
legop3 c9842d7ba5 adjust 2026-07-17 14:28:44 -04:00
legop3 d7fb15d891 slopcurrent 2026-07-17 14:20:06 -04:00
legop3 1cd0e05b4a Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-07-17 13:21:24 -04:00
legop3 7927768731 ad bb worker to ignore 2026-07-17 13:21:16 -04:00
legop3 d9cb0c76b6 Merge pull request #16 from legop3/wiifit
Wiifit
2026-07-17 02:18:33 -04:00
legop3 351cb24458 slop 2026-07-17 02:15:47 -04:00
legop3 679563862d slop 2026-07-17 02:12:03 -04:00
legop3 8655cde0f1 slop 2026-07-17 01:55:59 -04:00
legop3 12090f23be slop 2026-07-17 01:22:27 -04:00
legop3 557b4b81a2 slop 2026-07-17 01:04:21 -04:00
legop3 0add90714b slop 2026-07-17 00:51:24 -04:00
legop3 fe64ec7758 slop 2026-07-17 00:14:43 -04:00
legop3 10f71edaf1 slop 2026-07-17 00:06:55 -04:00
legop3 e97d4056fa slop 2026-07-16 23:47:32 -04:00
legop3 c228bb107f slop 2026-07-16 23:30:01 -04:00
legop3 06aeca660b slop 2026-07-16 23:12:09 -04:00
legop3 4bd228547a slop 2026-07-16 22:52:03 -04:00
legop3 35561495b4 slop 2026-07-16 22:35:07 -04:00
legop3 8a9205b5b7 slop 2026-07-16 22:26:38 -04:00
legop3 a6c569ada4 slop 2026-07-16 22:09:53 -04:00
legop3 0d352d326d slop 2026-07-16 22:01:18 -04:00
legop3 7bc08af160 slop 2026-07-16 21:36:44 -04:00
legop3 9177e53fbf ptz queue stuff fix 2026-07-16 19:58:52 -04:00
legop3 5be5ad3b17 add route for ptz page 2026-07-16 19:47:20 -04:00
legop3 99bc00e96b replay panel and PTZ ui improvements 2026-07-16 19:25:18 -04:00
legop3 002b174259 fix chat ack waiting for commands to finish 2026-07-16 17:47:58 -04:00
legop3 e28ccc5e66 better /display ptz operator popup 2026-07-16 17:42:55 -04:00
legop3 efae430d65 snapshot user threshold.. 2026-07-16 17:29:00 -04:00
legop3 0c9df78070 Merge pull request #15 from legop3/commandsidequest
Commandsidequest
2026-07-16 17:09:41 -04:00
88 changed files with 6361 additions and 1068 deletions
+1
View File
@@ -33,3 +33,4 @@ server/data/identity.sqlite
server/data/barcode-games.json server/data/barcode-games.json
server/data/identity.sqlite-shm server/data/identity.sqlite-shm
server/data/identity.sqlite-wal server/data/identity.sqlite-wal
server/src/services/balanceBoardService/native/balance_board_worker
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+93 -6
View File
@@ -3,6 +3,7 @@ package roverd
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"os" "os"
@@ -14,7 +15,7 @@ import (
) )
const ( const (
hostStatsInterval = 5 * time.Second hostStatsInterval = 1 * time.Second
rootFilesystem = "/" rootFilesystem = "/"
) )
@@ -63,7 +64,24 @@ type WiFiStats struct {
TXBytes *uint64 `json:"txBytes,omitempty"` TXBytes *uint64 `json:"txBytes,omitempty"`
RXPackets *uint64 `json:"rxPackets,omitempty"` RXPackets *uint64 `json:"rxPackets,omitempty"`
TXPackets *uint64 `json:"txPackets,omitempty"` TXPackets *uint64 `json:"txPackets,omitempty"`
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
UploadMbps *float64 `json:"uploadMbps,omitempty"`
InactiveMs *int `json:"inactiveMs,omitempty"` InactiveMs *int `json:"inactiveMs,omitempty"`
// networkSampledAt records the instant associated with the kernel byte
// counters. Keeping it out of JSON lets the websocket loop calculate rates
// with monotonic Go timestamps without expanding the browser contract with
// an implementation-only value.
networkSampledAt time.Time
}
// networkRateSample is scoped to one rover websocket connection. A new
// connection intentionally starts a new baseline so counters from an old boot
// or network interface lifetime can never create an artificial traffic spike.
type networkRateSample struct {
rxBytes uint64
txBytes uint64
sampledAt time.Time
} }
// CollectHostStats gathers every source independently so one missing kernel // CollectHostStats gathers every source independently so one missing kernel
@@ -370,12 +388,81 @@ func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
return nil, err return nil, err
} }
// The interface is used only to ask iw about the active connection. It is // The interface is used only for local collection. It is not copied into
// not copied into WiFiStats because the UI does not need to expose it. // WiFiStats because the UI does not need to expose Linux device names.
if err := enrichWiFiWithIW(ctx, iface, stats); err != nil { iwErr := enrichWiFiWithIW(ctx, iface, stats)
return stats, err
// Read the kernel counters after iw because iw also provides cumulative
// station counters. The kernel interface values deliberately win: they are
// the host-traffic source used for both the cumulative display and Mbps math.
// Link capacity still comes independently from iw's bitrate fields.
counterErr := enrichWiFiWithNetworkCounters(iface, stats)
return stats, errors.Join(counterErr, iwErr)
} }
return stats, nil
func enrichWiFiWithNetworkCounters(iface string, stats *WiFiStats) error {
basePath := "/sys/class/net/" + iface + "/statistics/"
rxBytes, err := readUintFile(basePath + "rx_bytes")
if err != nil {
return fmt.Errorf("read %s receive bytes: %w", iface, err)
}
txBytes, err := readUintFile(basePath + "tx_bytes")
if err != nil {
return fmt.Errorf("read %s transmit bytes: %w", iface, err)
}
stats.RXBytes = &rxBytes
stats.TXBytes = &txBytes
// Capture the timestamp immediately beside the counter reads so unrelated
// host-stat collection latency cannot distort the elapsed-time divisor.
stats.networkSampledAt = time.Now()
return nil
}
func readUintFile(path string) (uint64, error) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
}
func applyNetworkThroughput(stats *WiFiStats, previous *networkRateSample) *networkRateSample {
if stats == nil || stats.RXBytes == nil || stats.TXBytes == nil || stats.networkSampledAt.IsZero() {
// Do not discard the last valid baseline during a temporary read failure.
// The next successful calculation then covers the full elapsed interval and
// remains an accurate average for all traffic transferred during the gap.
return previous
}
current := &networkRateSample{
rxBytes: *stats.RXBytes,
txBytes: *stats.TXBytes,
sampledAt: stats.networkSampledAt,
}
if previous == nil {
return current
}
elapsed := current.sampledAt.Sub(previous.sampledAt).Seconds()
// Linux counters can return to zero after an interface reset. Re-baselining
// on any decrease prevents unsigned underflow from becoming a huge false
// throughput spike in the host-stat card.
if elapsed <= 0 || current.rxBytes < previous.rxBytes || current.txBytes < previous.txBytes {
return current
}
downloadMbps := bytesToMbps(current.rxBytes-previous.rxBytes, elapsed)
uploadMbps := bytesToMbps(current.txBytes-previous.txBytes, elapsed)
stats.DownloadMbps = &downloadMbps
stats.UploadMbps = &uploadMbps
return current
}
func bytesToMbps(byteDelta uint64, elapsedSeconds float64) float64 {
// Mbps uses decimal megabits, matching network equipment and link-rate
// conventions: eight bits per byte and 1,000,000 bits per megabit.
return roundOneDecimal((float64(byteDelta) * 8) / elapsedSeconds / 1_000_000)
} }
func readWirelessStats() (string, *WiFiStats, error) { func readWirelessStats() (string, *WiFiStats, error) {
+79
View File
@@ -0,0 +1,79 @@
package roverd
import (
"testing"
"time"
)
func TestApplyNetworkThroughputCalculatesMbpsFromActualElapsedTime(t *testing.T) {
startedAt := time.Unix(100, 0)
previous := &networkRateSample{rxBytes: 1_000, txBytes: 2_000, sampledAt: startedAt}
rxBytes := uint64(2_001_000)
txBytes := uint64(1_002_000)
stats := &WiFiStats{
RXBytes: &rxBytes,
TXBytes: &txBytes,
networkSampledAt: startedAt.Add(2 * time.Second),
}
next := applyNetworkThroughput(stats, previous)
if stats.DownloadMbps == nil || *stats.DownloadMbps != 8.0 {
t.Fatalf("expected 8.0 Mbps download, got %v", stats.DownloadMbps)
}
if stats.UploadMbps == nil || *stats.UploadMbps != 4.0 {
t.Fatalf("expected 4.0 Mbps upload, got %v", stats.UploadMbps)
}
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
t.Fatalf("expected current counters to become the next baseline, got %#v", next)
}
}
func TestApplyNetworkThroughputFirstSampleOnlyEstablishesBaseline(t *testing.T) {
rxBytes := uint64(100)
txBytes := uint64(200)
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: time.Unix(100, 0)}
next := applyNetworkThroughput(stats, nil)
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
t.Fatalf("expected no rates for the first sample, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
}
if next == nil {
t.Fatal("expected the first valid sample to establish a baseline")
}
}
func TestApplyNetworkThroughputCounterResetEstablishesNewBaseline(t *testing.T) {
startedAt := time.Unix(100, 0)
previous := &networkRateSample{rxBytes: 10_000, txBytes: 20_000, sampledAt: startedAt}
rxBytes := uint64(10)
txBytes := uint64(20)
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: startedAt.Add(time.Second)}
next := applyNetworkThroughput(stats, previous)
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
t.Fatalf("expected no rates after a counter reset, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
}
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
t.Fatalf("expected reset counters to become the new baseline, got %#v", next)
}
}
func TestApplyNetworkThroughputInvalidElapsedTimeEstablishesNewBaseline(t *testing.T) {
sampledAt := time.Unix(100, 0)
previous := &networkRateSample{rxBytes: 100, txBytes: 200, sampledAt: sampledAt}
rxBytes := uint64(200)
txBytes := uint64(300)
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: sampledAt}
next := applyNetworkThroughput(stats, previous)
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
t.Fatalf("expected no rates with zero elapsed time, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
}
if next == nil || next.sampledAt != sampledAt {
t.Fatalf("expected invalid timing sample to become the new baseline, got %#v", next)
}
}
+8 -1
View File
@@ -531,14 +531,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
} }
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) { func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
var previousNetworkSample *networkRateSample
send := func() bool { send := func() bool {
// Host stats are collected on demand so each outbound message describes // Host stats are collected on demand so each outbound message describes
// the current Pi state. Collection failures are encoded into the stats // the current Pi state. Collection failures are encoded into the stats
// payload, which keeps this telemetry path from closing the rover socket. // payload, which keeps this telemetry path from closing the rover socket.
stats := CollectHostStats(ctx)
// Throughput is derived here because this loop owns the ordered, periodic
// samples for one connection. CollectHostStats stays independent, while a
// reconnect automatically receives a clean counter baseline.
previousNetworkSample = applyNetworkThroughput(stats.WiFi, previousNetworkSample)
msg := hostStatsMessage{ msg := hostStatsMessage{
Type: "hostStats", Type: "hostStats",
Timestamp: time.Now().UnixMilli(), Timestamp: time.Now().UnixMilli(),
Stats: CollectHostStats(ctx), Stats: stats,
} }
if err := writeJSON(ctx, conn, msg); err != nil { if err := writeJSON(ctx, conn, msg); err != nil {
c.log.Printf("host stats send failed: %v", err) c.log.Printf("host stats send failed: %v", err)
+5 -2
View File
@@ -7,8 +7,9 @@
- not allowed - not allowed
- snapshots - snapshots
- non-turn video - non-turn video
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots) - snapshots (rover non-active turn holders and PTZ non-operators see snapshots after the user threshold is exceeded)
- live (rover non-active turn holders and PTZ non-operators can get full video) - live (rover non-active turn holders and PTZ non-operators can get full video)
- userThreshold (snapshots turn on when controllable users exceed this number)
- external spectator video - external spectator video
- snapshots (external spectators are only allowed snapshots) - snapshots (external spectators are only allowed snapshots)
- live (external spectators can get full video) - live (external spectators can get full video)
@@ -23,7 +24,9 @@
```yaml ```yaml
bandwidthSavings: bandwidthSavings:
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
nonTurnVideo: "snapshots" # snapshots | live nonTurnVideo:
mode: "snapshots" # snapshots | live
userThreshold: 0 # snapshots turn on when controllable users exceed this number
externalSpectatorVideo: "snapshots" # snapshots | live externalSpectatorVideo: "snapshots" # snapshots | live
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
``` ```
+15 -4
View File
@@ -60,10 +60,16 @@ bandwidthSavings:
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not # verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
# notAllowed: every identity is limited to one driver tab # notAllowed: every identity is limited to one driver tab
multiTabProtection: "verifiedOnly" multiTabProtection: "verifiedOnly"
# Live video for users who are attached to a source but do not currently own # Video for users who are attached to a source but do not currently own its
# its active turn. "snapshots" saves upload bandwidth; "live" allows full # active turn. "snapshots" saves upload bandwidth; "live" allows full video
# video whenever the normal mode/visibility rules allow it. # whenever the normal mode/visibility rules allow it.
nonTurnVideo: "snapshots" nonTurnVideo:
mode: "snapshots"
# Snapshot mode activates only when controllable users exceed this number.
# A controllable user is attached to a rover or PTZ as operator/queue, not a
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
# actually attached to a controllable source.
userThreshold: 0
# Live video for spectators outside the local network. Local spectators are # Live video for spectators outside the local network. Local spectators are
# not restricted by this switch because LAN traffic is not the upload limit. # not restricted by this switch because LAN traffic is not the upload limit.
externalSpectatorVideo: "snapshots" externalSpectatorVideo: "snapshots"
@@ -171,6 +177,11 @@ kinect:
# camera cache; it only gates browser-requested broadcasts. # camera cache; it only gates browser-requested broadcasts.
captureCooldownMs: 10000 captureCooldownMs: 10000
balanceBoard:
# The server installer always prepares Bluetooth and the kernel driver. This
# switch only starts the service and shows its small live-weight panel.
enabled: false
buttonBox: buttonBox:
enabled: false enabled: false
+1
View File
@@ -46,6 +46,7 @@ require('./src/services/buttonBoxService');
require('./src/services/barcodeScannerService'); require('./src/services/barcodeScannerService');
require('./src/services/barcodeGameService'); require('./src/services/barcodeGameService');
require('./src/services/kinectService'); require('./src/services/kinectService');
require('./src/services/balanceBoardService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
require('./src/services/replayEngineV2'); require('./src/services/replayEngineV2');
+44 -3
View File
@@ -16,6 +16,8 @@ MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots" SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments" REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules" KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This installer must be run with sudo/root." >&2 echo "This installer must be run with sudo/root." >&2
@@ -30,6 +32,8 @@ fi
TARGET_USER="$SUDO_USER" TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR" SERVER_DIR="$SCRIPT_DIR"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml" CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml" MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh" ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
@@ -134,7 +138,11 @@ dnf install -y \
gstreamer1-rtsp-server \ gstreamer1-rtsp-server \
libfreenect \ libfreenect \
libfreenect-devel \ libfreenect-devel \
libusb1-devel >/dev/null libusb1-devel \
bluez \
wiiuse \
wiiuse-devel \
libcap >/dev/null
NODE_BIN="$(command -v node)" NODE_BIN="$(command -v node)"
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE" echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
@@ -166,12 +174,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make" runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
fi fi
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
echo " Building native Balance Board bridge..."
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
exit 1
fi
# Only this small audited bridge needs the management socket used for the
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
# front-button reconnects. Never grant either capability to node or the full
# multirover service executable.
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
fi
if [[ ! -f "$CONFIG_PATH" ]]; then if [[ ! -f "$CONFIG_PATH" ]]; then
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH" cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH" chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service." echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
fi fi
# Bluetoothd remains responsible for discovery and the one-time bond, but its
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
# 0x13 before the Balance Board worker can listen for the board's front-button
# reconnect. This dedicated rover server gives those two HID listeners to the
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
# required by systemd before replacing the vendor unit's command in a drop-in.
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
EOF
chmod 0644 "$BLUETOOTH_OVERRIDE"
systemctl daemon-reload
systemctl enable bluetooth.service
systemctl restart bluetooth.service
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT trap 'rm -rf "$tmpdir"' EXIT
@@ -266,8 +305,8 @@ EOF
cat > "$MULTIROVER_SERVICE" <<EOF cat > "$MULTIROVER_SERVICE" <<EOF
[Unit] [Unit]
Description=Multi-Roomba Rover control server Description=Multi-Roomba Rover control server
After=network-online.target mediamtx.service After=network-online.target mediamtx.service bluetooth.service
Wants=network-online.target Wants=network-online.target bluetooth.service
[Service] [Service]
User=$TARGET_USER User=$TARGET_USER
@@ -304,3 +343,5 @@ echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters." echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Kinect/libfreenect packages and udev permissions were installed." echo "Kinect/libfreenect packages and udev permissions were installed."
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies." echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
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-B8ElczOE.js"></script> <script type="module" crossorigin src="/assets/index-C-g10Rjz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ZpgWUPKf.css"> <link rel="stylesheet" crossorigin href="/assets/index-BwjDTdpq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+32 -8
View File
@@ -10,7 +10,10 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({ const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
multiTabProtection: 'verifiedOnly', multiTabProtection: 'verifiedOnly',
nonTurnVideo: 'snapshots', nonTurnVideo: Object.freeze({
mode: 'snapshots',
userThreshold: 0,
}),
externalSpectatorVideo: 'snapshots', externalSpectatorVideo: 'snapshots',
externalSpectatorAccess: 'on', externalSpectatorAccess: 'on',
}); });
@@ -25,6 +28,23 @@ function normalizeEnum(value, allowed, fallback) {
return allowed.has(normalized) ? normalized : fallback; return allowed.has(normalized) ? normalized : fallback;
} }
function normalizeNonTurnVideo(value) {
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const threshold = Number(raw.userThreshold);
/*
userThreshold is intentionally "greater than", not "greater than or equal".
A value of 4 means the first four controllable users can keep live non-turn
video, and the fifth controllable user activates snapshot saving. Invalid
or negative values fall back to zero, which preserves always-on snapshots
for any real non-turn participant.
*/
const userThreshold = Number.isFinite(threshold) ? Math.max(0, Math.floor(threshold)) : 0;
return {
mode: normalizeEnum(raw.mode, VIDEO_MODES, DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo.mode),
userThreshold,
};
}
function buildBandwidthSavingsPolicy(config = loadConfig()) { function buildBandwidthSavingsPolicy(config = loadConfig()) {
const raw = config.bandwidthSavings || {}; const raw = config.bandwidthSavings || {};
return { return {
@@ -33,11 +53,7 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
MULTI_TAB_MODES, MULTI_TAB_MODES,
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection, DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
), ),
nonTurnVideo: normalizeEnum( nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
raw.nonTurnVideo,
VIDEO_MODES,
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
),
externalSpectatorVideo: normalizeEnum( externalSpectatorVideo: normalizeEnum(
raw.externalSpectatorVideo, raw.externalSpectatorVideo,
VIDEO_MODES, VIDEO_MODES,
@@ -72,8 +88,16 @@ function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } =
return !isVerified && !isAdmin; return !isVerified && !isAdmin;
} }
function shouldUseSnapshotsForNonTurnVideo() { function shouldUseSnapshotsForNonTurnVideo({ controllableUserCount = 0 } = {}) {
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots'; const { nonTurnVideo } = getBandwidthSavingsPolicy();
if (nonTurnVideo.mode !== 'snapshots') return false;
/*
The threshold is evaluated centrally so MediaMTX auth, socket-issued video
tokens, PTZ authorization, and browser session state all agree. Using a
strict greater-than comparison makes the configured value read like the
maximum number of controllable users allowed before snapshots start.
*/
return Math.max(0, Number(controllableUserCount) || 0) > nonTurnVideo.userThreshold;
} }
function shouldUseSnapshotsForExternalSpectatorVideo() { function shouldUseSnapshotsForExternalSpectatorVideo() {
+5
View File
@@ -46,6 +46,7 @@ function buildFeatureFlags(config = loadConfig()) {
const kinectConfig = config.kinect || {}; const kinectConfig = config.kinect || {};
const buttonBoxConfig = config.buttonBox || {}; const buttonBoxConfig = config.buttonBox || {};
const barcodeScannerConfig = config.barcodeScanner || {}; const barcodeScannerConfig = config.barcodeScanner || {};
const balanceBoardConfig = config.balanceBoard || {};
const barcodeGamesConfig = config.barcodeGames || {}; const barcodeGamesConfig = config.barcodeGames || {};
const socialsConfig = config.socials || {}; const socialsConfig = config.socials || {};
const interInstanceConfig = config.interInstance || {}; const interInstanceConfig = config.interInstance || {};
@@ -68,6 +69,10 @@ function buildFeatureFlags(config = loadConfig()) {
kinect: asBoolean(kinectConfig.enabled), kinect: asBoolean(kinectConfig.enabled),
buttonBox: asBoolean(buttonBoxConfig.enabled), buttonBox: asBoolean(buttonBoxConfig.enabled),
barcodeScanner, barcodeScanner,
// The worker performs its own runtime availability reporting. Advertising
// the feature from the explicit config switch lets the UI show useful
// commissioning and hardware-error states even before a board is paired.
balanceBoard: asBoolean(balanceBoardConfig.enabled),
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)), barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
lift: Boolean( lift: Boolean(
homeAssistant && homeAssistant &&
@@ -0,0 +1,179 @@
// Balance Board Hardware Bridge
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
// Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js.
const { spawn } = require('child_process');
const EventEmitter = require('events');
const path = require('path');
const WORKER_PATH =
process.env.BALANCE_BOARD_WORKER ||
path.join(__dirname, 'native', 'balance_board_worker');
const RESTART_DELAY_MS = 2000;
const STDERR_LOG_INTERVAL_MS = 5000;
function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) {
const events = new EventEmitter();
let worker = null;
let stdoutBuffer = '';
let stopped = false;
let restarting = false;
let restartTimer = null;
let lastStderrLogAt = 0;
let suppressedStderrLines = 0;
let currentAddress = address;
function emitProtocolError(message) {
events.emit('message', {
type: 'status',
state: 'error',
error: message,
});
}
function processStdout(chunk) {
stdoutBuffer += chunk.toString('utf8');
let newline = stdoutBuffer.indexOf('\n');
while (newline !== -1) {
const line = stdoutBuffer.slice(0, newline).trim();
stdoutBuffer = stdoutBuffer.slice(newline + 1);
if (line) {
try {
const message = JSON.parse(line);
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
throw new Error('message needs a type');
}
events.emit('message', message);
} catch (err) {
// A corrupted stdout line means measurement framing can no longer be
// trusted. Surface the exact line rather than silently discarding a
// potential hardware failure that would otherwise look like zero kg.
emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`);
logger?.warn?.('Balance Board worker protocol error', { line, error: err.message });
}
}
newline = stdoutBuffer.indexOf('\n');
}
}
function scheduleRestart() {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => {
restartTimer = null;
start();
}, RESTART_DELAY_MS);
}
function start() {
if (stopped || (worker && !worker.killed)) return;
stdoutBuffer = '';
const child = spawn(WORKER_PATH, [], {
env: {
...process.env,
BALANCE_BOARD_ADDRESS: currentAddress || '',
BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
worker = child;
child.stdout.on('data', processStdout);
child.stderr.on('data', (chunk) => {
const text = chunk.toString('utf8').trim();
if (!text) return;
const now = Date.now();
if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) {
const suffix = suppressedStderrLines
? ` (${suppressedStderrLines} worker stderr lines suppressed)`
: '';
logger?.warn?.(`Balance Board worker: ${text}${suffix}`);
lastStderrLogAt = now;
suppressedStderrLines = 0;
} else {
suppressedStderrLines += 1;
}
});
child.on('error', (err) => {
if (worker === child) worker = null;
emitProtocolError(`balance board worker failed to start: ${err.message}`);
scheduleRestart();
});
child.on('close', (code, signal) => {
if (worker === child) worker = null;
if (!stopped) {
// Admin unpair deliberately replaces the worker with an empty address.
// Do not turn that expected exit into a red hardware-error state while
// still using the normal restart scheduler for the replacement.
if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`);
restarting = false;
scheduleRestart();
}
});
}
function stop() {
stopped = true;
restarting = false;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (!worker) return;
const child = worker;
worker = null;
try {
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
} catch (_err) {
// The worker may have already closed stdin while its exit event is still
// queued. SIGTERM below remains the reliable cleanup path.
}
child.kill('SIGTERM');
setTimeout(() => {
// bluetoothctl may still be finishing a bounded pairing command inside a
// worker thread. Do not let that delay server shutdown indefinitely.
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
}, 1500).unref();
}
function restart() {
if (stopped) return;
if (!worker) {
start();
return;
}
const child = worker;
restarting = true;
try {
// An admin forget changes the address used in the child environment. A
// controlled restart lets the replacement worker start with that new
// value, while the existing close handler remains the single owner of
// delayed respawn and avoids overlapping Bluetooth listeners.
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
} catch (_err) {
// The child may have already closed stdin; SIGTERM below still guarantees
// that it cannot keep listening for the address that was just forgotten.
}
child.kill('SIGTERM');
setTimeout(() => {
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
}, 1500).unref();
}
return {
events,
start,
stop,
restart,
setAddress(nextAddress) {
// The factory can be created before first commissioning. Preserve the
// newly paired address for later bridge restarts in the same Node process
// instead of reverting the replacement worker to discovery mode.
currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : '';
},
};
}
module.exports = {
createBalanceBoardHardware,
};
@@ -0,0 +1,575 @@
// Balance Board Service
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
// Scope: Stores pairing and admin zero calibration, then publishes status plus live four-corner weight.
const fs = require('fs');
const { execFile } = require('child_process');
const { promisify } = require('util');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('balanceBoardService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isFeatureEnabled } = require('../../helpers/features');
const { isAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const { createBalanceBoardHardware } = require('./hardware');
const events = new EventEmitter();
const enabled = isFeatureEnabled('balanceBoard');
const rawConfig = loadConfig().balanceBoard || {};
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers';
const CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
const ZERO_SAMPLE_COUNT = 10;
const ZERO_SAMPLE_INTERVAL_MS = 1000;
const ZERO_MAX_SAMPLE_AGE_MS = 1500;
const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
const RECORD_PERSIST_DELAY_MS = 1000;
const execFileAsync = promisify(execFile);
const ALERT_COLOR = '#38bdf8';
function emptyZeroCorners() {
return Object.fromEntries(CORNER_KEYS.map((key) => [key, 0]));
}
function normalizeStoredCorners(value) {
if (!value || typeof value !== 'object') return emptyZeroCorners();
return Object.fromEntries(CORNER_KEYS.map((key) => {
const number = Number(value[key]);
return [key, Number.isFinite(number) ? Math.max(0, number) : 0];
}));
}
function emptyStore() {
return {
address: '',
zeroCorners: emptyZeroCorners(),
zeroedAt: null,
recordKg: 0,
recordedAt: null,
};
}
function loadStore() {
try {
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
const recordKg = Number.isFinite(Number(parsed?.recordKg))
? roundedWeight(parsed.recordKg)
: 0;
const recordedAt = Number.isFinite(Number(parsed?.recordedAt))
? Number(parsed.recordedAt)
: null;
return {
address,
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
zeroedAt,
recordKg,
recordedAt: recordKg > 0 ? recordedAt : null,
};
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
return emptyStore();
}
}
function persistStore() {
fs.mkdirSync(DATA_DIR, { recursive: true });
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
fs.renameSync(temporary, STORE_PATH);
}
function roundedWeight(value) {
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
}
function cornerWeightsKg(corners = {}) {
// Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
// admin zero calibration below is an installation baseline layered on top of
// this factory conversion; it must never replace the hardware calibration.
return {
topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
bottomRight: roundedWeight((Number(corners.bottomRight) || 0) / 100),
topLeft: roundedWeight((Number(corners.topLeft) || 0) / 100),
bottomLeft: roundedWeight((Number(corners.bottomLeft) || 0) / 100),
};
}
function subtractZero(rawCorners) {
const baseline = store.zeroedAt ? store.zeroCorners : emptyZeroCorners();
return Object.fromEntries(CORNER_KEYS.map((key) => [
key,
roundedWeight(Math.max(0, rawCorners[key] - baseline[key])),
]));
}
function totalCornerWeight(corners) {
return roundedWeight(CORNER_KEYS.reduce((total, key) => total + corners[key], 0));
}
let store = enabled ? loadStore() : emptyStore();
let hardware = null;
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
let detail = enabled
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
: 'Balance Board support is disabled.';
let connected = false;
let batteryPercent = null;
let latestFrame = null;
let latestRawCorners = null;
let latestRawFrameAt = 0;
let zeroTimer = null;
let recordPersistTimer = null;
let zeroSamples = [];
let zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
let previousWorkerState = '';
let lastAlertKey = '';
let unpairing = false;
function sendRawAlert(state, message = '') {
const rawMessage = message ? `${state}: ${message}` : state;
if (rawMessage === lastAlertKey) return;
lastAlertKey = rawMessage;
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
}
function sendStatusAlert(workerState, message = '') {
const shouldAlert =
workerState === 'connected' ||
workerState === 'sleeping' ||
workerState === 'connection-failed' ||
workerState === 'error' ||
(workerState === 'waiting' && previousWorkerState === 'connected');
previousWorkerState = workerState;
if (!shouldAlert) return;
// Keep the alert at the same system-level boundary as the worker protocol:
// state first, followed by its exact detail when one exists. The service does
// not reinterpret failures as friendlier product copy, but still collapses
// identical retries so a failing reconnect cannot flood the activity feed.
sendRawAlert(workerState, message);
}
function getState() {
return {
enabled,
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
connected,
status,
detail,
batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
calibration: {
calibrated: Boolean(store.zeroedAt),
zeroedAt: store.zeroedAt,
...zeroProgress,
},
};
}
function clearRecordPersistTimer() {
if (!recordPersistTimer) return;
clearTimeout(recordPersistTimer);
recordPersistTimer = null;
}
function scheduleRecordPersistence() {
clearRecordPersistTimer();
// A person driving onto the board produces many successively larger frames.
// Waiting until the maximum has stopped changing prevents a synchronous JSON
// rewrite for every 20 Hz sensor frame while still saving a settled record
// promptly enough to survive an ordinary service restart.
recordPersistTimer = setTimeout(() => {
recordPersistTimer = null;
persistStore();
}, RECORD_PERSIST_DELAY_MS);
recordPersistTimer.unref?.();
}
function publishLatestFrame() {
if (!latestFrame) return;
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
}
function resetWeightRecord() {
clearRecordPersistTimer();
// Reset means "start measuring the record from now." If the board currently
// has a load, that current measurement is the first candidate in the new
// period. Saving it immediately avoids briefly showing zero before the next
// live frame restores the same weight as the record.
const currentWeight = connected && latestFrame ? roundedWeight(latestFrame.totalKg) : 0;
store.recordKg = currentWeight;
store.recordedAt = currentWeight > 0 ? Date.now() : null;
persistStore();
if (latestFrame) {
latestFrame = {
...latestFrame,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
};
publishLatestFrame();
}
events.emit('change', { state: getState() });
sendRawAlert('record-reset');
}
function updateStatus(nextStatus, nextDetail) {
const normalizedStatus = String(nextStatus || 'unknown');
const normalizedDetail = String(nextDetail || '');
if (status === normalizedStatus && detail === normalizedDetail) return;
status = normalizedStatus;
detail = normalizedDetail;
events.emit('change', { state: getState() });
}
function publishCalibrationState() {
// Calibration progress belongs in the ordinary session payload because it
// changes only once per second for ten seconds. Live 20 Hz weights remain in
// their dedicated room and never trigger a full-session broadcast.
events.emit('change', { state: getState() });
}
function clearZeroTimer() {
if (!zeroTimer) return;
clearInterval(zeroTimer);
zeroTimer = null;
}
function failZeroCalibration(error, { alert = true } = {}) {
clearZeroTimer();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: String(error || 'Calibration failed'),
};
publishCalibrationState();
if (alert) sendRawAlert('zero-failed', zeroProgress.error);
}
function finishZeroCalibration() {
clearZeroTimer();
// A single average could hide movement that returns to its starting point.
// Sum every corner's complete ten-second range before accepting the result so
// distributed movement cannot hide below four independent thresholds. Retain
// three decimals so averaging ten centi-kilogram samples does not throw away
// useful sub-centi-kilogram precision in the persisted baseline.
const combinedRange = CORNER_KEYS.reduce((totalRange, key) => {
const values = zeroSamples.map((sample) => sample[key]);
return totalRange + Math.max(...values) - Math.min(...values);
}, 0);
if (combinedRange > ZERO_MAX_COMBINED_RANGE_KG) {
failZeroCalibration('Load moved during the ten-second calibration.');
return;
}
store.zeroCorners = Object.fromEntries(CORNER_KEYS.map((key) => {
const average = zeroSamples.reduce((sum, sample) => sum + sample[key], 0) /
zeroSamples.length;
return [key, Math.round(average * 1000) / 1000];
}));
store.zeroedAt = Date.now();
// A new zero changes the meaning of every adjusted weight, so an old record
// cannot be compared with measurements under the new baseline.
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: ZERO_SAMPLE_COUNT,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
publishCalibrationState();
sendRawAlert('zeroed');
}
function takeZeroSample() {
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
failZeroCalibration('Live Balance Board data stopped during calibration.');
return;
}
zeroSamples.push({ ...latestRawCorners });
zeroProgress = {
active: true,
samplesCollected: zeroSamples.length,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
publishCalibrationState();
if (zeroSamples.length >= ZERO_SAMPLE_COUNT) finishZeroCalibration();
}
function startZeroCalibration() {
if (zeroProgress.active) throw new Error('Balance Board zero calibration is already running');
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
throw new Error('The Balance Board must be connected and sending weight data');
}
zeroSamples = [];
zeroProgress = {
active: true,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
publishCalibrationState();
sendRawAlert('zeroing');
// Delaying the first sample by one interval makes this a real ten-second
// calibration rather than ten rapid reads followed by nine seconds of UI.
zeroTimer = setInterval(takeZeroSample, ZERO_SAMPLE_INTERVAL_MS);
}
function processFrame(message = {}) {
const rawCorners = cornerWeightsKg(message.corners);
latestRawCorners = rawCorners;
latestRawFrameAt = Date.now();
if (Number.isFinite(Number(message.batteryPercent))) {
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
}
connected = true;
updateStatus('connected', 'Live weight is updating.');
const adjustedCorners = subtractZero(rawCorners);
const totalKg = totalCornerWeight(adjustedCorners);
if (totalKg > store.recordKg) {
// Store only adjusted weight so the displayed record uses the same admin
// zero baseline as the live total and all four corner readings.
store.recordKg = totalKg;
store.recordedAt = Date.now();
scheduleRecordPersistence();
}
latestFrame = {
totalKg,
corners: adjustedCorners,
batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
};
publishLatestFrame();
}
function handleWorkerMessage(message = {}) {
if (message.type === 'frame') {
processFrame(message);
return;
}
if (message.type === 'paired') {
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
if (address && address !== store.address) {
store.address = address;
// A zero baseline belongs to one physical board and whatever permanent
// platform/load was present when an admin calibrated it. Never carry that
// baseline across commissioning a different Bluetooth identity.
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
}
hardware?.setAddress(address);
sendRawAlert('paired');
updateStatus('connecting', 'Paired. Connecting to the board now.');
return;
}
if (message.type !== 'status') return;
const workerState = String(message.state || 'unknown');
sendStatusAlert(workerState, message.error || '');
if (workerState === 'commissioning') {
updateStatus('starting', 'Starting Bluetooth discovery.');
} else if (workerState === 'discovering') {
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
} else if (workerState === 'pairing') {
updateStatus('pairing', 'Board found. Pairing now.');
} else if (workerState === 'connected') {
connected = true;
updateStatus('connected', 'Connected. Waiting for live weight data.');
} else if (workerState === 'link-detected') {
connected = false;
// The native bridge can now distinguish which half of the board's HID
// connection reached the server. Preserve that diagnostic until both
// channels arrive; the generic text remains for the outbound Sync flow.
updateStatus('connecting', message.error || 'Board responded. Reading its sensor calibration.');
} else if (workerState === 'connection-failed') {
connected = false;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
} else if (workerState === 'sleeping') {
connected = false;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Board slept during calibration.');
updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
} else if (workerState === 'waiting') {
connected = false;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
} else if (workerState === 'error') {
connected = false;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
updateStatus('error', message.error || 'The Balance Board worker stopped.');
}
}
io.on('connection', (socket) => {
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(FRAME_ROOM);
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
cb({ success: true });
});
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
}
try {
startZeroCalibration();
cb({ success: true });
} catch (err) {
cb({ error: err.message || 'Failed to start Balance Board zero calibration' });
}
});
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
}
try {
resetWeightRecord();
cb({ success: true });
} catch (err) {
logger.error('Failed to reset Balance Board weight record', err);
cb({ error: err.message || 'Failed to reset the Balance Board weight record' });
}
});
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
}
if (unpairing) {
cb({ error: 'The Balance Board is already being unpaired' });
return;
}
unpairing = true;
const address = store.address;
let bluetoothWarning = '';
try {
if (address) {
try {
// A complete forget removes both sources of remembered identity. If
// only the JSON address or only the BlueZ bond were removed, the next
// red-Sync attempt could inherit half of the previous relationship.
await execFileAsync('bluetoothctl', ['remove', address], { timeout: 10000 });
} catch (err) {
bluetoothWarning = String(
err?.stderr || err?.message || 'BlueZ did not remove the bond',
).trim();
logger.warn('Balance Board BlueZ bond removal failed', bluetoothWarning);
}
}
store.address = '';
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
clearZeroTimer();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
connected = false;
batteryPercent = null;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
previousWorkerState = '';
hardware?.setAddress('');
hardware?.restart();
updateStatus('starting', 'Starting Bluetooth discovery.');
sendRawAlert('unpaired');
cb({ success: true, warning: bluetoothWarning || null });
} catch (err) {
logger.error('Failed to unpair Balance Board', err);
cb({ error: err.message || 'Failed to unpair the Balance Board' });
} finally {
unpairing = false;
}
});
});
if (enabled) {
hardware = createBalanceBoardHardware({
logger,
address: store.address,
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
});
hardware.events.on('message', handleWorkerMessage);
hardware.start();
} else {
logger.info('Balance Board disabled by config');
}
function installShutdownHooks() {
const shutdown = () => {
clearZeroTimer();
// A record may still be inside the short debounce window when the process
// receives a normal shutdown signal. Flush that newest maximum before the
// hardware worker stops so a clean restart cannot lose it.
if (recordPersistTimer) {
clearRecordPersistTimer();
persistStore();
}
hardware?.stop();
};
process.once('exit', shutdown);
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
}
installShutdownHooks();
module.exports = {
getState,
balanceBoardEvents: events,
};
@@ -0,0 +1,22 @@
CXX ?= g++
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
# profile: current BlueZ requests medium link security for a bonded board, and
# the original Balance Board rejects that negotiation before an input device is
# created.
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
LDLIBS += -lwiiuse -lbluetooth -pthread
TARGET := balance_board_worker
SRC := balance_board_worker.cpp
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(SRC)
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
clean:
rm -f $(TARGET)
File diff suppressed because it is too large Load Diff
+20 -15
View File
@@ -8,7 +8,7 @@ const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders'); const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
const { broadcastMessage, broadcastTyping } = require('./broadcast'); const { broadcastMessage, broadcastTyping } = require('./broadcast');
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications'); const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
const { runChatTextCommand } = require('./textCommands'); const { isTextCommand, runChatTextCommand } = require('./textCommands');
function createHandlers({ sendSystemMessage }) { function createHandlers({ sendSystemMessage }) {
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) { async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
@@ -53,21 +53,26 @@ function createHandlers({ sendSystemMessage }) {
maybeSendAccessNotice(message, sendSystemMessage); maybeSendAccessNotice(message, sendSystemMessage);
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
try { const command = isTextCommand(clean);
// Commands sent from site chat should still be visible as normal chat // Chat delivery is complete once validation, broadcast, and local side
// messages. Running the command after broadcast preserves the user-visible // effects above have succeeded. A command may wait on Home Assistant,
// transcript while keeping permissions and command execution entirely on // hardware, replay preparation, or an external transport, so tying the
// the server. // socket acknowledgement to command completion leaves the browser's send
const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage }); // promise pending and makes its input state appear stuck. Acknowledge now;
cb({ success: true, command: ranCommand }); // command replies continue through the normal Rover bot message stream.
return; cb({ success: true, command });
} catch (err) {
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
return;
}
cb({ success: true }); if (command) {
// Deliberately do not await this promise. runChatTextCommand already turns
// ordinary command failures into visible bot messages; this final catch
// protects the service from an unexpected setup/programming failure and
// cannot attempt a second acknowledgement after the UI has moved on.
void runChatTextCommand({ text: clean, socket, sendSystemMessage }).catch((err) => {
logger.warn('Chat command failed after acknowledgement', { socket: socket?.id, error: err.message });
sendSystemMessage(`Command failed: ${err.message || 'unknown error'}`, { nickname: 'Rover bot', bot: true });
});
}
return;
} }
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) { function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
+40 -1
View File
@@ -9,6 +9,7 @@ const { isDeterred } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService'); const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness'); const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const overcurrentProtectionService = require('../overcurrentProtectionService');
const pendingCommands = new Map(); // id -> { roverId } const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin } const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
@@ -93,6 +94,31 @@ function issueCommand(roverId, payload) {
return id; return id;
} }
/*
The protection service owns decisions about when a held command must be
resent at a lower output. Injecting this raw transport function keeps those
resends on the same rover websocket path as every other server command while
avoiding a circular dependency from the protection service back into this
socket-facing module.
*/
overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
const blockedUntil = driveCooldowns.get(roverId);
const safetyCooldownActive = blockedUntil && Date.now() < blockedUntil;
if (safetyCooldownActive && getCommandMotionMagnitude(payload?.type, payload) > 0) {
/*
Private-rover and dock safety own the existing command cooldown map. A
rate-limited protection resend must respect those independent systems;
otherwise this new service could restart drive or brushes immediately
after an unrelated safety feature deliberately stopped them. Returning
false tells the protection service to retry after the cooldown instead of
recording an output that never reached the rover.
*/
return false;
}
issueCommand(roverId, payload);
return true;
});
function handleAck(msg) { function handleAck(msg) {
const pending = pendingCommands.get(msg.id); const pending = pendingCommands.get(msg.id);
if (!pending) return; if (!pending) return;
@@ -226,7 +252,7 @@ io.on('connection', (socket) => {
if (type === 'audioLevels') { if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed'); throw new Error('audioLevels command is service-managed');
} }
const payload = data ? { ...data } : {}; let payload = data ? { ...data } : {};
if (type === 'headlight' && isHeadlightBlocked()) { if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId }); logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' }); reply({ ignored: true, reason: 'darknessActive' });
@@ -291,6 +317,19 @@ io.on('connection', (socket) => {
}); });
} }
} }
if (type === 'drive' || type === 'motors') {
/*
Role is supplied at the command boundary because telemetry does not
identify the operator who produced the active motor intent. Admin and
lockdown commands therefore enter the service explicitly bypassed;
they are recorded for status visibility but are never scaled, blocked,
or countermanded by a later sensor frame.
*/
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
bypassed: isAdminSocket,
});
}
const id = issueCommand(roverId, { type, ...payload }); const id = issueCommand(roverId, { type, ...payload });
logger.info('Queued command', socket.id, roverId, type); logger.info('Queued command', socket.id, roverId, type);
if (shouldRecordTurnActivity(type, payload)) { if (shouldRecordTurnActivity(type, payload)) {
@@ -4,7 +4,12 @@
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService'); const { renderIndexHtml, renderOgImage } = require('../embedService');
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => { /*
Every client-side BrowserRouter entry point must also be an explicit HTTP
entry point. Including /ptz here lets direct loads and browser refreshes
receive the same rendered index document as navigation from the driver page.
*/
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz'], async (req, res) => {
try { try {
const html = await renderIndexHtml(req); const html = await renderIndexHtml(req);
res.type('html').send(html); res.type('html').send(html);
@@ -78,6 +78,7 @@ module.exports = {
setLightColor: runtimeEngine.setLightColor, setLightColor: runtimeEngine.setLightColor,
setLightWhite: runtimeEngine.setLightWhite, setLightWhite: runtimeEngine.setLightWhite,
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState, setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
setRandomColorScene: runtimeEngine.setRandomColorScene,
setLightsLockedOn: runtimeEngine.setLightsLockedOn, setLightsLockedOn: runtimeEngine.setLightsLockedOn,
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn, toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
homeAssistantEvents: events, homeAssistantEvents: events,
@@ -196,6 +196,72 @@ function createRuntimeEngine(deps) {
}; };
} }
function createBrightRandomRgbColor() {
// A completely random RGB triplet frequently produces colors that are very
// dark, gray, or visually indistinguishable from a bulb being off. Choosing
// a random hue at full saturation and brightness still gives every bulb a
// genuinely random color while keeping the requested room effect vivid.
const hueSegment = Math.random() * 6;
const segmentIndex = Math.floor(hueSegment);
const risingChannel = Math.round((hueSegment - segmentIndex) * 255);
const fallingChannel = 255 - risingChannel;
switch (segmentIndex) {
case 0: return [255, risingChannel, 0];
case 1: return [fallingChannel, 255, 0];
case 2: return [0, 255, risingChannel];
case 3: return [0, fallingChannel, 255];
case 4: return [risingChannel, 0, 255];
default: return [255, 0, fallingChannel];
}
}
async function setRandomColorScene(options = {}) {
const source = String(options?.source || 'homeAssistant:setRandomColorScene');
const entities = Array.from(entityConfig.values()).map((meta) => ({
meta,
state: entityState.get(meta.id) || buildState(meta, null),
}));
// RGB capability comes from Home Assistant's live supported_color_modes
// snapshot. This avoids a second operator-maintained list and makes newly
// replaced bulbs automatically participate once Home Assistant reports
// their capabilities. Everything else is turned off, including switches
// and white-only lights, exactly matching the scene's requested boundary.
const operations = entities.map(({ meta, state }) => {
if (state.supportsColor) {
return setLightColor(meta.id, createBrightRandomRgbColor());
}
return setEntityState(meta.id, 'off', { source: `${source}:non-rgb-off` });
});
const results = await Promise.allSettled(operations);
const failures = results
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
.filter(({ result }) => result.status === 'rejected')
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
const succeeded = results
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
.filter(({ result }) => result.status === 'fulfilled')
.map(({ entityId }) => entityId);
if (failures.length) {
logger.warn('Some Home Assistant random color scene updates failed', {
total: entities.length,
failed: failures.length,
failures,
});
}
return {
source,
total: entities.length,
colorLights: entities.filter(({ state }) => state.supportsColor).length,
nonColorEntities: entities.filter(({ state }) => !state.supportsColor).length,
succeeded,
failures,
};
}
async function setEntityLockedOnWhite(entityId, options = {}) { async function setEntityLockedOnWhite(entityId, options = {}) {
const meta = entityConfig.get(entityId); const meta = entityConfig.get(entityId);
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite'); const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
@@ -498,6 +564,7 @@ function createRuntimeEngine(deps) {
setLightColor, setLightColor,
setLightWhite, setLightWhite,
setAllControllableEntitiesState, setAllControllableEntitiesState,
setRandomColorScene,
setAllControllableEntitiesLockedOnWhite, setAllControllableEntitiesLockedOnWhite,
setLightsLockedOn, setLightsLockedOn,
toggleLightsLockedOn, toggleLightsLockedOn,
@@ -33,6 +33,34 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
return; return;
} }
const isAdmin = Boolean(message.actor?.isAdmin);
const adminActions = new Set(['status', 'lock', 'unlock']);
// The lights namespace intentionally contains both public feature actions
// and room-policy actions. The shared dispatcher applies the current server
// mode to the feature as a whole; this focused check preserves the stronger
// historical permission on status/lock/unlock without making on/off/colors
// admin-only during normal open or turns operation.
if (adminActions.has(action) && !isAdmin) {
await message.reply({
content: 'Only admins can manage the room-light lock.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
// An active lock is a policy boundary for ordinary feature commands. Admin
// lock management remains available, but public scene commands must not
// silently defeat a locked-on or locked-off room state.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if ((action === 'on' || action === 'off' || action === 'colors') && lightPolicy.locked) {
await message.reply({
content: describeLightPolicy(lightPolicy),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action === 'status') { if (action === 'status') {
await message.reply({ await message.reply({
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}), content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
@@ -41,9 +69,35 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
return; return;
} }
if (action === 'on' || action === 'off' || action === 'colors') {
try {
const result = action === 'colors'
? await homeAssistantService.setRandomColorScene({ source: 'bot-command:lights:colors' })
: await homeAssistantService.setAllControllableEntitiesState(action, {
source: `bot-command:lights:${action}`,
});
const failed = result?.failures?.length || 0;
const succeeded = result?.succeeded?.length || 0;
const description = action === 'colors'
? `Applied random colors to ${result?.colorLights || 0} RGB lights and requested off for ${result?.nonColorEntities || 0} non-RGB lights.`
: `Turned ${action} ${succeeded} room lights.`;
const failureSuffix = failed ? ` ${failed} failed.` : '';
await message.reply({
content: sanitizeMentions(`${description}${failureSuffix}`),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
return;
}
if (action !== 'lock' && action !== 'unlock') { if (action !== 'lock' && action !== 'unlock') {
await message.reply({ await message.reply({
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`, content: `Invalid lights command. Use \`${commandPrefix} lights on\`, \`${commandPrefix} lights off\`, \`${commandPrefix} lights colors\`, \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
allowedMentions: { parse: [], repliedUser: false }, allowedMentions: { parse: [], repliedUser: false },
}); });
return; return;
@@ -93,9 +93,10 @@ function createCommandHandlers(deps) {
return; return;
} }
// Actions in this set can change operational safety or access policy, so // Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Room // lockdown mode narrows them from normal admins to lockdown admins. Lights
// light locking belongs here because it can force the physical room lights // is included because its lock/unlock subcommands change room policy. Its
// on and disables ordinary Home Assistant room controls for everyone else. // ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']); const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode'; const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
@@ -3,8 +3,8 @@
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations. // Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = { const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] }, system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] }, admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] },
features: { title: 'Features', names: ['lift', 'neato'] }, features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] }, discord: { title: 'Discord', names: ['bridge'] },
}; };
@@ -19,7 +19,18 @@ function buildCommandRegistry(prefix, timeCommand) {
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' }, mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' }, reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' }, goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' }, lights: {
category: 'features',
summary: 'Control room lights or manage the admin light lock.',
usage: [
`${prefix} lights <on|off|colors>`,
`${prefix} lights <status|lock|unlock>`,
],
access: 'Light controls are public unless server access is restricted; lock controls require admin',
permission: 'access-mode',
requiredFeature: 'homeAssistant',
unavailableLabel: 'Home Assistant',
},
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' }, kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' }, verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' }, deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
@@ -0,0 +1,587 @@
// Overcurrent Protection Service
// Purpose: Owns fleet-wide, server-authoritative motor stress calculation and command limiting.
// Scope: Keeps overcurrent policy out of rover-manager sensor orchestration while combining command intent with decoded telemetry.
const logger = require('../../globals/logger').child('overcurrentProtectionService');
const DEFAULT_CONFIG = Object.freeze({
minimumUsefulWheelIntent: 75,
stressGrace: 0.25,
wheelProgressWindowSec: 0.4,
encoderNoiseFloorMmPerSec: 15,
fullStallProgressRatio: 0.1,
movingProgressRatio: 0.6,
movingOrUnknownRatePerSec: 0.2,
fullStallRatePerSec: 0.6,
wheelRecoveryDelaySec: 0.75,
wheelRecoveryRatePerSec: 0.4,
brushOvercurrentRatePerSec: 1,
brushRecoveryRatePerSec: 0.75,
clearBeforeUnlockSec: 0.75,
outputRateMs: 250,
maxTelemetryDeltaSec: 0.5,
});
const MOTOR_KEYS = Object.freeze(['leftWheel', 'rightWheel', 'mainBrush', 'sideBrush']);
function clampUnit(value) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1, value));
}
function finiteNumber(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function createMotorState() {
return {
overcurrent: false,
commandedSpeed: 0,
measuredSpeed: null,
currentMa: null,
stallFactor: 0,
progressRatio: null,
classification: 'unknown',
progressSamples: [],
windowCommandSign: 0,
clearSec: 0,
stress: 0,
cap: 1,
};
}
function createRoverState() {
return {
bypassed: false,
lastTelemetryAt: 0,
driveIntent: { left: 0, right: 0 },
auxIntent: { main: 0, side: 0, vacuum: 0 },
driveBlocked: false,
requiresNeutral: false,
neutralSeen: false,
driveClearSec: 0,
stopReason: null,
lastDriveOutputAt: 0,
lastAuxOutputAt: 0,
lastDriveOutput: { left: 0, right: 0 },
lastAuxOutput: { main: 0, side: 0, vacuum: 0 },
motors: {
leftWheel: createMotorState(),
rightWheel: createMotorState(),
mainBrush: createMotorState(),
sideBrush: createMotorState(),
},
};
}
function createOvercurrentProtectionService(options = {}) {
const config = Object.freeze({ ...DEFAULT_CONFIG, ...(options.config || {}) });
const states = new Map();
let commandIssuer = typeof options.issueCommand === 'function' ? options.issueCommand : null;
function getState(roverId) {
const key = String(roverId || '');
if (!states.has(key)) states.set(key, createRoverState());
return states.get(key);
}
function resetProtectionState(state, { bypassed = state.bypassed } = {}) {
/*
An administrator bypass is a change of authority, not merely a cap of
one. Clearing accumulated stress here prevents a previous user's event
from unexpectedly affecting the first command after authority changes.
Both command intents are cleared before the caller records the new command
so telemetry cannot apply a previous operator's held drive or brush value
after authority changes.
*/
state.bypassed = Boolean(bypassed);
state.lastTelemetryAt = 0;
state.driveBlocked = false;
state.requiresNeutral = false;
state.neutralSeen = false;
state.driveClearSec = 0;
state.stopReason = null;
state.driveIntent = { left: 0, right: 0 };
state.auxIntent = { main: 0, side: 0, vacuum: 0 };
state.lastDriveOutput = { left: 0, right: 0 };
state.lastAuxOutput = { main: 0, side: 0, vacuum: 0 };
state.lastDriveOutputAt = 0;
state.lastAuxOutputAt = 0;
MOTOR_KEYS.forEach((key) => {
state.motors[key] = createMotorState();
});
}
function configureCommandIssuer(nextIssuer) {
commandIssuer = typeof nextIssuer === 'function' ? nextIssuer : null;
}
function calculateCap(stress) {
/*
The grace region absorbs short mechanical events such as initial wheel
acceleration and direction changes. Above it, the remaining stress range
maps linearly to output so the cap reaches exactly zero at hard-stop
stress instead of leaving a small command applied to a stalled motor.
*/
const grace = clampUnit(config.stressGrace);
if (stress <= grace) return 1;
const usableRange = Math.max(0.0001, 1 - grace);
return clampUnit(1 - (stress - grace) / usableRange);
}
function getDriveCap(state) {
return Math.min(state.motors.leftWheel.cap, state.motors.rightWheel.cap);
}
function scaleDrive(state, driveDirect = state.driveIntent) {
if (state.bypassed) return { ...driveDirect };
if (state.driveBlocked) return { left: 0, right: 0 };
const cap = getDriveCap(state);
return {
left: Math.round(finiteNumber(driveDirect?.left) * cap),
right: Math.round(finiteNumber(driveDirect?.right) * cap),
};
}
function scaleAux(state, motorPwm = state.auxIntent) {
if (state.bypassed) return { ...motorPwm };
return {
main: Math.round(finiteNumber(motorPwm?.main) * state.motors.mainBrush.cap),
side: Math.round(finiteNumber(motorPwm?.side) * state.motors.sideBrush.cap),
// Create 2/Roomba 600 does not expose a vacuum overcurrent bit, so this
// service must not imply that it can measure or limit vacuum motor stress.
vacuum: Math.round(finiteNumber(motorPwm?.vacuum)),
};
}
function hasDriveIntent(state) {
return Boolean(state.driveIntent.left || state.driveIntent.right);
}
function hasAuxIntent(state) {
return Boolean(state.auxIntent.main || state.auxIntent.side || state.auxIntent.vacuum);
}
function driveOutputsEqual(left, right) {
return left.left === right.left && left.right === right.right;
}
function auxOutputsEqual(left, right) {
return left.main === right.main && left.side === right.side && left.vacuum === right.vacuum;
}
function issueAdjustedCommand(roverId, payload) {
if (!commandIssuer) return false;
try {
/*
The injected issuer is the raw server-to-roverd transport function.
Calling it here intentionally avoids routing a service-generated update
through the socket authorization/filter path a second time.
*/
return commandIssuer(roverId, payload) !== false;
} catch (err) {
logger.warn('Failed to issue overcurrent protection command', {
roverId,
type: payload?.type,
error: err.message,
});
return false;
}
}
function protectCommand(roverId, type, payload = {}, context = {}) {
const state = getState(roverId);
const bypassed = Boolean(context.bypassed);
if (bypassed !== state.bypassed) {
resetProtectionState(state, { bypassed });
}
if (type === 'drive' && payload?.driveDirect) {
state.driveIntent = {
left: finiteNumber(payload.driveDirect.left),
right: finiteNumber(payload.driveDirect.right),
};
if (bypassed) {
state.lastDriveOutput = { ...state.driveIntent };
state.lastDriveOutputAt = Date.now();
return { ...payload, driveDirect: { ...state.driveIntent } };
}
const neutral = !state.driveIntent.left && !state.driveIntent.right;
if (neutral) {
/*
A real neutral command proves the operator released their controls.
Merely observing zero encoder motion cannot provide that assurance,
because a held command against an obstruction also produces no motion.
*/
state.neutralSeen = true;
if (state.driveBlocked && state.driveClearSec >= config.clearBeforeUnlockSec) {
state.driveBlocked = false;
state.requiresNeutral = false;
state.stopReason = null;
}
}
const driveDirect = scaleDrive(state);
state.lastDriveOutput = { ...driveDirect };
state.lastDriveOutputAt = Date.now();
return { ...payload, driveDirect };
}
if (type === 'motors' && payload?.motorPwm) {
state.auxIntent = {
main: finiteNumber(payload.motorPwm.main),
side: finiteNumber(payload.motorPwm.side),
vacuum: finiteNumber(payload.motorPwm.vacuum),
};
const motorPwm = scaleAux(state);
state.lastAuxOutput = { ...motorPwm };
state.lastAuxOutputAt = Date.now();
return { ...payload, motorPwm };
}
return payload;
}
function resetWheelProgressWindow(motor, commandSign = 0) {
motor.progressSamples = [];
motor.windowCommandSign = commandSign;
motor.progressRatio = null;
motor.classification = 'unknown';
motor.stallFactor = 0;
}
function appendWheelProgressSample(motor, sample) {
motor.progressSamples.push(sample);
let windowSec = motor.progressSamples.reduce((total, entry) => total + entry.deltaSec, 0);
/*
Keep an actual rolling time window rather than periodically clearing a
bucket. If the oldest sample crosses the boundary, retain only its
proportional tail so classification does not depend on sensor cadence.
*/
while (motor.progressSamples.length && windowSec > config.wheelProgressWindowSec) {
const excessSec = windowSec - config.wheelProgressWindowSec;
const oldest = motor.progressSamples[0];
if (oldest.deltaSec <= excessSec) {
motor.progressSamples.shift();
windowSec -= oldest.deltaSec;
continue;
}
const retainedFraction = (oldest.deltaSec - excessSec) / oldest.deltaSec;
oldest.deltaSec -= excessSec;
oldest.expectedDistance *= retainedFraction;
oldest.alignedDistance *= retainedFraction;
windowSec = config.wheelProgressWindowSec;
}
return windowSec;
}
function classifyWheelProgress(motor, windowSec) {
if (windowSec < config.wheelProgressWindowSec * 0.9) return;
const totals = motor.progressSamples.reduce(
(result, sample) => ({
expectedDistance: result.expectedDistance + sample.expectedDistance,
alignedDistance: result.alignedDistance + sample.alignedDistance,
}),
{ expectedDistance: 0, alignedDistance: 0 },
);
if (totals.expectedDistance <= 0) return;
/*
Signed, command-aligned distance lets forward/backward encoder wobble
cancel over the window. The fixed noise floor then removes small residual
bias from gearbox lash or a wheel module rocking without real travel.
*/
const averageExpectedSpeed = totals.expectedDistance / windowSec;
const averageAlignedSpeed = totals.alignedDistance / windowSec;
const usefulProgressSpeed = Math.max(0, averageAlignedSpeed - config.encoderNoiseFloorMmPerSec);
const progressRatio = clampUnit(usefulProgressSpeed / averageExpectedSpeed);
const ratioRange = Math.max(0.0001, config.movingProgressRatio - config.fullStallProgressRatio);
const stallFactor = clampUnit((config.movingProgressRatio - progressRatio) / ratioRange);
motor.progressRatio = progressRatio;
motor.stallFactor = stallFactor;
motor.classification = progressRatio <= config.fullStallProgressRatio
? 'stalled'
: progressRatio >= config.movingProgressRatio
? 'moving'
: 'partial';
}
function updateWheelMotor(motor, { overcurrent, command, intent, measured, currentMa, deltaSec }) {
const commandNumber = finiteNumber(command);
const commandMagnitude = Math.abs(commandNumber);
const commandSign = Math.sign(commandNumber);
const intentMagnitude = Math.abs(finiteNumber(intent));
// Null is intentionally checked before Number conversion because
// Number(null) is zero, which would falsely turn missing telemetry into a
// perfectly stalled wheel.
const measuredNumber = measured == null ? null : Number(measured);
const measuredValid = Number.isFinite(measuredNumber);
const usefulIntent = intentMagnitude >= config.minimumUsefulWheelIntent;
motor.overcurrent = Boolean(overcurrent);
motor.commandedSpeed = commandNumber;
motor.measuredSpeed = measuredValid ? measuredNumber : null;
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
motor.clearSec = motor.overcurrent ? 0 : motor.clearSec + deltaSec;
if (!motor.overcurrent) {
resetWheelProgressWindow(motor, commandSign);
} else if (!usefulIntent || commandMagnitude <= 0 || !measuredValid) {
// Low commands and missing telemetry are real overcurrent events, but
// neither supplies enough evidence to label the wheel mechanically stalled.
// Raw operator intent decides whether the classifier remains meaningful;
// the progressively smaller applied output must not erase an already
// confirmed stall merely because protection itself reduced it below the
// normal command threshold.
resetWheelProgressWindow(motor, commandSign);
} else {
if (motor.windowCommandSign !== commandSign) {
// Samples from opposite requested directions cannot share a progress
// window because their aligned distances describe different motion.
resetWheelProgressWindow(motor, commandSign);
}
if (deltaSec > 0) {
const windowSec = appendWheelProgressSample(motor, {
deltaSec,
expectedDistance: commandMagnitude * deltaSec,
alignedDistance: measuredNumber * commandSign * deltaSec,
});
classifyWheelProgress(motor, windowSec);
}
}
const riseRate = config.movingOrUnknownRatePerSec
+ (config.fullStallRatePerSec - config.movingOrUnknownRatePerSec) * motor.stallFactor;
const recoveryAllowed = !motor.overcurrent && motor.clearSec >= config.wheelRecoveryDelaySec;
motor.stress = clampUnit(
motor.stress
+ (motor.overcurrent
? riseRate * deltaSec
: recoveryAllowed
? -config.wheelRecoveryRatePerSec * deltaSec
: 0),
);
motor.cap = calculateCap(motor.stress);
}
function updateBrushMotor(motor, { overcurrent, command, currentMa, deltaSec }) {
motor.overcurrent = Boolean(overcurrent);
motor.commandedSpeed = finiteNumber(command);
motor.measuredSpeed = null;
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
motor.stallFactor = 0;
motor.stress = clampUnit(
motor.stress
+ (motor.overcurrent
? config.brushOvercurrentRatePerSec * deltaSec
: -config.brushRecoveryRatePerSec * deltaSec),
);
motor.cap = calculateCap(motor.stress);
}
function maybeStopDrive(roverId, state) {
if (state.bypassed || state.driveBlocked || !hasDriveIntent(state)) return;
const stalledWheel = ['leftWheel', 'rightWheel'].find((key) => state.motors[key].stress >= 1);
if (!stalledWheel) return;
state.driveBlocked = true;
state.requiresNeutral = true;
state.neutralSeen = false;
state.driveClearSec = 0;
state.stopReason = stalledWheel;
state.lastDriveOutputAt = Date.now();
state.lastDriveOutput = { left: 0, right: 0 };
issueAdjustedCommand(roverId, {
type: 'drive',
driveDirect: { left: 0, right: 0 },
});
logger.warn('Stopped rover after persistent wheel overcurrent', {
roverId,
motor: stalledWheel,
});
}
function maybeResendScaledOutputs(roverId, state, now) {
if (state.bypassed) return;
/*
A held keyboard/gamepad value may not emit another browser command while
telemetry continues changing the cap. Rate-limited resends make each new
server calculation effective without requiring the user to move the
control again or flooding the Pi websocket at sensor-frame cadence.
*/
const nextDriveOutput = scaleDrive(state);
if (
hasDriveIntent(state)
&& !state.driveBlocked
&& !driveOutputsEqual(nextDriveOutput, state.lastDriveOutput)
&& now - state.lastDriveOutputAt >= config.outputRateMs
) {
const issued = issueAdjustedCommand(roverId, {
type: 'drive',
driveDirect: nextDriveOutput,
});
if (issued) {
state.lastDriveOutputAt = now;
state.lastDriveOutput = { ...nextDriveOutput };
}
}
const nextAuxOutput = scaleAux(state);
if (
hasAuxIntent(state)
&& !auxOutputsEqual(nextAuxOutput, state.lastAuxOutput)
&& now - state.lastAuxOutputAt >= config.outputRateMs
) {
const issued = issueAdjustedCommand(roverId, {
type: 'motors',
motorPwm: nextAuxOutput,
});
if (issued) {
state.lastAuxOutputAt = now;
state.lastAuxOutput = { ...nextAuxOutput };
}
}
}
function processTelemetry(roverId, sensors = {}, now = Date.now()) {
const state = getState(roverId);
const timestamp = finiteNumber(now, Date.now());
const previousAt = state.lastTelemetryAt;
state.lastTelemetryAt = timestamp;
if (state.bypassed) {
/*
Admin bypass means telemetry remains visible but cannot build hidden
stress or schedule a delayed stop. Motor observations are still copied
into the public snapshot so administrators can see the hardware warning
while deliberately retaining full control.
*/
state.driveBlocked = false;
state.requiresNeutral = false;
state.neutralSeen = false;
state.driveClearSec = 0;
state.stopReason = null;
}
const rawDeltaSec = previousAt > 0 ? Math.max(0, (timestamp - previousAt) / 1000) : 0;
const deltaSec = state.bypassed
? 0
: Math.min(config.maxTelemetryDeltaSec, rawDeltaSec);
const flags = sensors?.wheelOvercurrents || {};
const speeds = sensors?.wheelSpeedsMmPerSecond || {};
updateWheelMotor(state.motors.leftWheel, {
overcurrent: flags.leftWheel,
command: state.lastDriveOutput.left,
intent: state.driveIntent.left,
measured: speeds.left,
currentMa: sensors?.wheelLeftCurrentMa,
deltaSec,
});
updateWheelMotor(state.motors.rightWheel, {
overcurrent: flags.rightWheel,
command: state.lastDriveOutput.right,
intent: state.driveIntent.right,
measured: speeds.right,
currentMa: sensors?.wheelRightCurrentMa,
deltaSec,
});
updateBrushMotor(state.motors.mainBrush, {
overcurrent: flags.mainBrush,
command: state.auxIntent.main,
currentMa: sensors?.mainBrushCurrentMa,
deltaSec,
});
updateBrushMotor(state.motors.sideBrush, {
overcurrent: flags.sideBrush,
command: state.auxIntent.side,
currentMa: sensors?.sideBrushCurrentMa,
deltaSec,
});
const wheelOvercurrent = Boolean(flags.leftWheel || flags.rightWheel);
state.driveClearSec = wheelOvercurrent ? 0 : state.driveClearSec + deltaSec;
if (
state.driveBlocked
&& state.neutralSeen
&& state.driveClearSec >= config.clearBeforeUnlockSec
) {
state.driveBlocked = false;
state.requiresNeutral = false;
state.stopReason = null;
}
maybeStopDrive(roverId, state);
maybeResendScaledOutputs(roverId, state, timestamp);
return getPublicState(roverId);
}
function getStatus(state) {
const anyOvercurrent = MOTOR_KEYS.some((key) => state.motors[key].overcurrent);
if (state.bypassed && anyOvercurrent) return 'bypassed';
if (state.driveBlocked) return 'stopped';
const anyLimited = MOTOR_KEYS.some((key) => state.motors[key].cap < 1);
if (anyLimited && anyOvercurrent) return 'limiting';
// The raw Roomba flag is useful operator information even while stress is
// still inside the transient grace region. Reporting it separately keeps
// HUD visibility immediate without falsely claiming output is being scaled.
if (anyOvercurrent) return 'overcurrent';
// Stress below the command-limiting grace threshold still represents a
// recent hardware event. Keeping recovery visible until it reaches zero
// prevents the HUD from vanishing the instant the raw flag clears.
const anyStress = MOTOR_KEYS.some((key) => state.motors[key].stress > 0);
if (anyStress) return 'recovering';
return 'idle';
}
function getPublicState(roverId) {
const state = getState(roverId);
const motors = MOTOR_KEYS.reduce((result, key) => {
// Rolling samples are internal evidence, not UI state. Excluding them
// keeps every sensor frame compact while exposing the resulting ratio and
// classification needed to explain the service's decision.
const { progressSamples: _progressSamples, windowCommandSign: _windowCommandSign, ...motor } = state.motors[key];
result[key] = motor;
return result;
}, {});
return {
status: getStatus(state),
bypassed: state.bypassed,
drive: {
cap: getDriveCap(state),
blocked: state.driveBlocked,
requiresNeutral: state.requiresNeutral,
clearSec: state.driveClearSec,
stopReason: state.stopReason,
},
motors,
config: { ...config },
};
}
function cleanupRover(roverId) {
states.delete(String(roverId || ''));
}
return {
configureCommandIssuer,
protectCommand,
processTelemetry,
getPublicState,
cleanupRover,
};
}
const service = createOvercurrentProtectionService();
module.exports = {
...service,
createOvercurrentProtectionService,
DEFAULT_CONFIG,
};
@@ -0,0 +1,327 @@
// Overcurrent Protection Service Tests
// Purpose: Verifies stress integration, administrator bypass, neutral recovery, and independent brush limiting.
// Scope: Exercises the service as a pure state machine with an injected command sink; no rover or socket process is started.
const assert = require('node:assert/strict');
const test = require('node:test');
const { createOvercurrentProtectionService } = require('./index');
function makeSensors(overrides = {}) {
return {
wheelOvercurrents: {
leftWheel: false,
rightWheel: false,
mainBrush: false,
sideBrush: false,
...(overrides.wheelOvercurrents || {}),
},
wheelSpeedsMmPerSecond: {
left: 300,
right: 300,
...(overrides.wheelSpeedsMmPerSecond || {}),
},
};
}
function createHarness(config = {}) {
const issued = [];
const service = createOvercurrentProtectionService({
config,
issueCommand: (roverId, payload) => issued.push({ roverId, payload }),
});
return { service, issued };
}
test('a short stalled-wheel spike remains inside the grace region', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start);
const snapshot = service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start + 100);
// Stress is accumulated with floating-point time arithmetic, so compare
// within a tiny tolerance instead of depending on an exact binary decimal.
assert.ok(Math.abs(snapshot.motors.leftWheel.stress - 0.02) < 1e-9);
assert.equal(snapshot.motors.leftWheel.cap, 1);
// The hardware event is visible immediately even though the grace region
// correctly leaves the command cap at full output.
assert.equal(snapshot.status, 'overcurrent');
});
test('persistent stalled-wheel overcurrent scales both wheels and then stops drive', () => {
const { service, issued } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 200 },
});
for (let step = 0; step <= 18; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
}), start + step * 100);
}
/*
The first 400 ms establish that the wheel is making no net progress. The
tuned stalled rate should then approachbut not yet crossthe hard-stop
boundary at 1.8 seconds.
*/
assert.equal(service.getPublicState('rover').drive.blocked, false);
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
}), start + 1900);
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.status, 'stopped');
assert.equal(snapshot.drive.blocked, true);
assert.equal(snapshot.drive.requiresNeutral, true);
assert.equal(snapshot.drive.stopReason, 'leftWheel');
assert.deepEqual(issued.at(-1), {
roverId: 'rover',
payload: { type: 'drive', driveDirect: { left: 0, right: 0 } },
});
/*
Before the hard stop, any rate-limited drive update must use one shared cap.
This preserves the requested curve instead of driving the healthy wheel at
full output around the mechanically obstructed side.
*/
const scaledDrive = issued.find((entry) => entry.payload.type === 'drive'
&& entry.payload.driveDirect.left > 0);
assert.ok(scaledDrive);
const leftScale = scaledDrive.payload.driveDirect.left / 300;
const rightScale = scaledDrive.payload.driveDirect.right / 200;
/*
Motor commands are integers, so applying one shared fractional cap can
round the two differently sized wheel commands by different sub-unit
amounts. A one-percent tolerance verifies the shared curve without
pretending integer transport preserves an exact floating-point ratio.
*/
assert.ok(Math.abs(leftScale - rightScale) < 0.01);
});
test('administrator commands and telemetry bypass all enforcement', () => {
const { service, issued } = createHarness({ outputRateMs: 0 });
const start = Date.now();
const command = service.protectCommand('rover', 'drive', {
driveDirect: { left: 500, right: -500 },
}, { bypassed: true });
for (let step = 0; step <= 20; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true, rightWheel: true },
wheelSpeedsMmPerSecond: { left: 0, right: 0 },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.deepEqual(command.driveDirect, { left: 500, right: -500 });
assert.equal(snapshot.status, 'bypassed');
assert.equal(snapshot.bypassed, true);
assert.equal(snapshot.motors.leftWheel.stress, 0);
assert.equal(snapshot.motors.rightWheel.stress, 0);
assert.equal(snapshot.drive.blocked, false);
assert.equal(issued.length, 0);
});
test('encoder wobble around zero is classified as a full stall', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 19; step += 1) {
const wobbleSpeed = step % 2 === 0 ? 10 : -9;
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: wobbleSpeed },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.motors.leftWheel.classification, 'stalled');
assert.equal(snapshot.motors.leftWheel.stallFactor, 1);
assert.equal(snapshot.drive.blocked, true);
});
test('overcurrent while a wheel keeps moving accumulates at the slower rate', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 20; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 240 },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.motors.leftWheel.classification, 'moving');
assert.equal(snapshot.motors.leftWheel.stallFactor, 0);
assert.equal(snapshot.drive.blocked, false);
assert.ok(snapshot.motors.leftWheel.stress < 0.6);
});
test('partial wheel progress produces an intermediate stall factor', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 5; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 105 },
}), start + step * 100);
}
const motor = service.getPublicState('rover').motors.leftWheel;
assert.equal(motor.classification, 'partial');
assert.ok(motor.stallFactor > 0 && motor.stallFactor < 1);
});
test('missing wheel speed remains unknown instead of becoming a full stall', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 20; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: null },
}), start + step * 100);
}
const snapshot = service.getPublicState('rover');
assert.equal(snapshot.motors.leftWheel.measuredSpeed, null);
assert.equal(snapshot.motors.leftWheel.classification, 'unknown');
assert.equal(snapshot.motors.leftWheel.stallFactor, 0);
assert.equal(snapshot.drive.blocked, false);
});
test('wheel comparison follows scaled output and resets after reversal', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 8; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start + step * 100);
}
const scaled = service.getPublicState('rover').motors.leftWheel.commandedSpeed;
assert.ok(scaled < 300);
service.protectCommand('rover', 'drive', {
driveDirect: { left: -300, right: -300 },
});
const reversed = service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: -250 },
}), start + 900);
assert.equal(reversed.motors.leftWheel.classification, 'unknown');
assert.equal(reversed.motors.leftWheel.progressRatio, null);
});
test('a stopped drive stays blocked until both clear time and neutral are observed', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
for (let step = 0; step <= 19; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start + step * 100);
}
for (let step = 20; step <= 52; step += 1) {
service.processTelemetry('rover', makeSensors(), start + step * 100);
}
assert.equal(service.getPublicState('rover').drive.blocked, true);
const heldCommand = service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
assert.deepEqual(heldCommand.driveDirect, { left: 0, right: 0 });
service.protectCommand('rover', 'drive', {
driveDirect: { left: 0, right: 0 },
});
assert.equal(service.getPublicState('rover').drive.blocked, false);
const resumed = service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
assert.deepEqual(resumed.driveDirect, { left: 300, right: 300 });
});
test('cleared wheel stress remains visible through the hold and drains to idle', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'drive', {
driveDirect: { left: 300, right: 300 },
});
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start);
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { leftWheel: true },
wheelSpeedsMmPerSecond: { left: 0 },
}), start + 100);
const justCleared = service.processTelemetry('rover', makeSensors(), start + 200);
assert.equal(justCleared.status, 'recovering');
assert.ok(justCleared.motors.leftWheel.stress > 0);
const held = service.processTelemetry('rover', makeSensors(), start + 800);
assert.equal(held.status, 'recovering');
assert.ok(held.motors.leftWheel.stress > 0);
const recovered = service.processTelemetry('rover', makeSensors(), start + 1000);
assert.equal(recovered.status, 'idle');
assert.equal(recovered.motors.leftWheel.stress, 0);
});
test('brush stress limits only the brush that reports overcurrent', () => {
const { service } = createHarness();
const start = Date.now();
service.protectCommand('rover', 'motors', {
motorPwm: { main: 100, side: 100, vacuum: 100 },
});
for (let step = 0; step <= 4; step += 1) {
service.processTelemetry('rover', makeSensors({
wheelOvercurrents: { mainBrush: true },
}), start + step * 100);
}
const protectedCommand = service.protectCommand('rover', 'motors', {
motorPwm: { main: 100, side: 100, vacuum: 100 },
});
assert.ok(protectedCommand.motorPwm.main < 100);
assert.equal(protectedCommand.motorPwm.side, 100);
assert.equal(protectedCommand.motorPwm.vacuum, 100);
});
+292 -31
View File
@@ -29,6 +29,16 @@ const PTZ_STREAM_PATH = 'ptz-camera';
const DEFAULT_ONVIF_PORT = 8000; const DEFAULT_ONVIF_PORT = 8000;
const DEFAULT_PROFILE_TOKEN = '003'; const DEFAULT_PROFILE_TOKEN = '003';
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000; const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
// The TrackMix exposes pan/tilt and zoom through the same ONVIF method but does
// not behave as if they were the same kind of motor. Pan/tilt runs smoothly from
// one long ContinuousMove; zoom advances in command-sized increments. Keep the
// timings separate so zoom can repeat quickly without restarting pan/tilt.
const MOTION_WATCHDOG_MS = 650;
const PAN_TILT_TIMEOUT_MS = 10000;
const PAN_TILT_RENEW_MS = 8000;
const ZOOM_PULSE_TIMEOUT_MS = 1000;
const ZOOM_REPEAT_MS = 120;
const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
// PTZ is a normal replay source now, so capture should be on unless the feature // PTZ is a normal replay source now, so capture should be on unless the feature
// explicitly disables replay for the camera. // explicitly disables replay for the camera.
const DEFAULT_REPLAY_ENABLED = true; const DEFAULT_REPLAY_ENABLED = true;
@@ -92,6 +102,14 @@ let publisherStderrSyncTimer = null;
let snapshotTimer = null; let snapshotTimer = null;
let spotlightVerifyTimer = null; let spotlightVerifyTimer = null;
let vendorStatePromise = Promise.resolve(); let vendorStatePromise = Promise.resolve();
let motionWatchdogTimer = null;
let panTiltRenewTimer = null;
let zoomRepeatTimer = null;
let desiredMotion = STOP_MOTION;
let pendingFullStopCommand = false;
let pendingPanTiltCommand = false;
let pendingZoomCommand = false;
let motionCommandPromise = null;
let lastSnapshotState = null; let lastSnapshotState = null;
const snapshotSubscribers = new Map(); const snapshotSubscribers = new Map();
const socketSnapshotSubscriptions = new Map(); const socketSnapshotSubscriptions = new Map();
@@ -339,6 +357,34 @@ function getChatTargetForSocket(socketId) {
}; };
} }
function getParticipantSocketIds() {
/*
PTZ has no roverManager record, so services that need a global "how many
controllable users are online" count need a tiny PTZ-owned participant list.
The operator and queue are the only users attached to this controllable
camera target; spectators merely viewing snapshots/live video are excluded.
*/
return Array.from(new Set([
state.operatorSocketId,
...state.queue,
].filter(Boolean)));
}
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
return ids.size;
}
function canSpeakThroughPtz(socket) { function canSpeakThroughPtz(socket) {
/* /*
PTZ chat uses roverId for identity, but the camera has its own queue rather PTZ chat uses roverId for identity, but the camera has its own queue rather
@@ -896,7 +942,10 @@ function revokeOperator(reason = 'release') {
state.deadline = null; state.deadline = null;
clearTurnTimer(); clearTurnTimer();
videoSessions.revokeWhere((info) => info.socketId === previous && info.sourceType === 'ptz'); videoSessions.revokeWhere((info) => info.socketId === previous && info.sourceType === 'ptz');
callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {}); // Operator handoff/disconnect must enter the same serialized stream as
// movement. A raw concurrent Stop could otherwise finish before an older
// ContinuousMove and allow that stale move to restart the camera afterward.
forceMotionStop(`operator-${reason}`);
events.emit('operator', { socketId: previous, action: 'release', reason }); events.emit('operator', { socketId: previous, action: 'release', reason });
} }
@@ -1061,26 +1110,235 @@ function normalizePresetCreateName(rawName) {
return name; return name;
} }
async function move(socket, payload = {}) { function normalizeMotionIntent(payload = {}) {
requireOperator(socket); return {
await initialize(); pan: clampUnit(payload.pan ?? payload.x),
const x = clampUnit(payload.pan ?? payload.x); tilt: clampUnit(payload.tilt ?? payload.y),
const y = clampUnit(payload.tilt ?? payload.y); zoom: clampUnit(payload.zoom),
const zoom = clampUnit(payload.zoom); };
await callOnvif('continuousMove', {
profileToken: state.profileToken,
x,
y,
zoom,
timeout: 1000,
});
return { ok: true };
} }
async function stop(socket) { function isMotionIdle(motion = STOP_MOTION) {
return !motion.pan && !motion.tilt && !motion.zoom;
}
function clearMotionWatchdog() {
if (!motionWatchdogTimer) return;
clearTimeout(motionWatchdogTimer);
motionWatchdogTimer = null;
}
function clearPanTiltRenewal() {
if (!panTiltRenewTimer) return;
clearTimeout(panTiltRenewTimer);
panTiltRenewTimer = null;
}
function clearZoomRepeat() {
if (!zoomRepeatTimer) return;
clearInterval(zoomRepeatTimer);
zoomRepeatTimer = null;
}
function panTiltMatches(left = STOP_MOTION, right = STOP_MOTION) {
return left.pan === right.pan && left.tilt === right.tilt;
}
function requestMotionCommands({ fullStop = false, panTilt = false, zoom = false } = {}) {
pendingFullStopCommand = pendingFullStopCommand || fullStop;
pendingPanTiltCommand = pendingPanTiltCommand || panTilt;
pendingZoomCommand = pendingZoomCommand || zoom;
if (
motionCommandPromise ||
(!pendingFullStopCommand && !pendingPanTiltCommand && !pendingZoomCommand)
) {
return motionCommandPromise || Promise.resolve();
}
/*
Keep one ONVIF request in flight at a time, but coalesce independently by
axis. A zoom timer can tick several times while the camera answers one SOAP
request; one pending boolean preserves the newest required pulse without
building a delayed command backlog that would continue after release.
*/
motionCommandPromise = (async () => {
while (pendingFullStopCommand || pendingPanTiltCommand || pendingZoomCommand) {
const sendFullStop = pendingFullStopCommand;
pendingFullStopCommand = false;
if (sendFullStop) {
try {
await initialize();
/*
Reserve ONVIF Stop for real all-axis safety events. The TrackMix
appears to treat even an axis-filtered Stop as global, so ordinary
user releases below use zero velocity instead.
*/
await callOnvif('stop', {
profileToken: state.profileToken,
panTilt: true,
zoom: true,
});
} catch (err) {
logger.warn('PTZ full stop command failed', { error: getErrorMessage(err) });
}
}
const sendPanTilt = pendingPanTiltCommand;
pendingPanTiltCommand = false;
if (sendPanTilt) {
const pan = desiredMotion.pan;
const tilt = desiredMotion.tilt;
try {
await initialize();
if (!pan && !tilt) {
/*
Zero pan/tilt velocity stops only that axis under ContinuousMove.
Do not use ONVIF Stop here: this TrackMix ignores the requested
axis filter and can also stop zoom that is still being held.
*/
await callOnvif('continuousMove', {
profileToken: state.profileToken,
x: 0,
y: 0,
onlySendPanTilt: true,
timeout: PAN_TILT_TIMEOUT_MS,
});
} else {
await callOnvif('continuousMove', {
profileToken: state.profileToken,
x: pan,
y: tilt,
onlySendPanTilt: true,
timeout: PAN_TILT_TIMEOUT_MS,
});
}
} catch (err) {
logger.warn('PTZ pan/tilt command failed', { error: getErrorMessage(err), pan, tilt });
}
}
const sendZoom = pendingZoomCommand;
pendingZoomCommand = false;
if (sendZoom) {
const zoom = desiredMotion.zoom;
try {
await initialize();
if (zoom) {
/*
The TrackMix does not advertise continuous zoom, but physical
testing showed each accepted zoom-only ContinuousMove advances one
step. Repeating this axis-only request restores responsive zoom
without resending or restarting the pan/tilt motor.
*/
await callOnvif('continuousMove', {
profileToken: state.profileToken,
zoom,
onlySendZoom: true,
timeout: ZOOM_PULSE_TIMEOUT_MS,
});
}
} catch (err) {
logger.warn('PTZ zoom command failed', { error: getErrorMessage(err), zoom });
}
}
}
})().finally(() => {
motionCommandPromise = null;
// Cover an intent arriving between the loop check and promise cleanup.
if (pendingFullStopCommand || pendingPanTiltCommand || pendingZoomCommand) {
requestMotionCommands();
}
});
return motionCommandPromise;
}
function armPanTiltRenewal() {
clearPanTiltRenewal();
if (!desiredMotion.pan && !desiredMotion.tilt) return;
/*
The camera requires a finite timeout. Renew close to the ten-second limit,
not on every browser heartbeat, so an unusually long hold stays continuous
without bringing back the quarter-second motor restarts.
*/
panTiltRenewTimer = setTimeout(() => {
panTiltRenewTimer = null;
requestMotionCommands({ panTilt: true });
armPanTiltRenewal();
}, PAN_TILT_RENEW_MS);
}
function syncZoomRepeater() {
clearZoomRepeat();
if (!desiredMotion.zoom) return;
// queueMotionIntent sends the first step once after configuring this timer;
// subsequent ticks retain the old fast hold cadence without a double pulse.
zoomRepeatTimer = setInterval(() => {
requestMotionCommands({ zoom: true });
}, ZOOM_REPEAT_MS);
}
function queueMotionIntent(motion, reason = 'input') {
const nextMotion = normalizeMotionIntent(motion);
const panTiltChanged = !panTiltMatches(nextMotion, desiredMotion);
const zoomChanged = nextMotion.zoom !== desiredMotion.zoom;
desiredMotion = nextMotion;
clearMotionWatchdog();
if (!isMotionIdle(nextMotion)) {
/*
Socket disconnect normally arrives quickly, but it is not a suitable motor
safety boundary. Every non-zero browser heartbeat replaces this timer; if
releases or subsequent heartbeats disappear, the server injects a zero
intent into the same serialized stream as ordinary control changes.
*/
motionWatchdogTimer = setTimeout(() => {
motionWatchdogTimer = null;
queueMotionIntent(STOP_MOTION, 'watchdog');
}, MOTION_WATCHDOG_MS);
}
/*
Identical browser heartbeats refresh only the watchdog. They must not touch
either motor scheduler: pan/tilt already has a long continuous command, and
zoom has its own 120 ms axis-only repeater.
*/
const sendPanTilt = panTiltChanged;
/*
A live-camera recording proved that both Stop(Zoom=true) and a zero-velocity
zoom ContinuousMove halt pan/tilt on this firmware. Zoom itself is step-based:
each non-zero pulse advances once and then settles. Releasing zoom therefore
means clearing its timer and any coalesced-but-unsent pulse, with no camera
command at all. The last transmitted pulse retains its finite one-second
timeout as a backstop.
*/
if (zoomChanged && !nextMotion.zoom) pendingZoomCommand = false;
const sendZoom = Boolean(nextMotion.zoom) && zoomChanged;
if (sendPanTilt) armPanTiltRenewal();
if (sendZoom) syncZoomRepeater();
if (zoomChanged && !nextMotion.zoom) clearZoomRepeat();
const pending = requestMotionCommands({ panTilt: sendPanTilt, zoom: sendZoom });
pending.catch(() => {});
return { ok: true, motion: desiredMotion, reason };
}
function acceptMotionIntent(socket, payload = {}) {
requireOperator(socket); requireOperator(socket);
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }); return queueMotionIntent(payload, 'operator-input');
return { ok: true }; }
function forceMotionStop(reason = 'safety-stop') {
/*
Lifecycle stops force a real all-axis ONVIF Stop even when local state is
already zero. The browser may have lost its final packet, or the camera may
have accepted a command whose response has not returned, so deduplicating a
safety stop would trust precisely the state we are trying to recover from.
*/
clearPanTiltRenewal();
clearZoomRepeat();
queueMotionIntent(STOP_MOTION, reason);
requestMotionCommands({ fullStop: true });
return motionCommandPromise || Promise.resolve();
} }
async function getStatus(socket) { async function getStatus(socket) {
@@ -1105,9 +1363,10 @@ async function gotoPreset(socket, payload = {}) {
Stop any continuous move before jumping to a preset. Without this, a held Stop any continuous move before jumping to a preset. Without this, a held
key or touch control can keep sending pan/tilt velocity while the camera is key or touch control can keep sending pan/tilt velocity while the camera is
trying to execute the absolute preset move, which makes the final position trying to execute the absolute preset move, which makes the final position
feel inconsistent. feel inconsistent. Await the serialized safety stop instead of issuing a
raw concurrent ONVIF request that could itself race an older movement.
*/ */
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {}); await forceMotionStop('preset').catch(() => {});
await callOnvif('gotoPreset', { await callOnvif('gotoPreset', {
profileToken: state.profileToken, profileToken: state.profileToken,
/* /*
@@ -1305,7 +1564,10 @@ function canRequestLiveVideo(socket) {
*/ */
return local || !shouldUseSnapshotsForExternalSpectatorVideo(); return local || !shouldUseSnapshotsForExternalSpectatorVideo();
} }
if (canUsePtzFeature(socket) && !shouldUseSnapshotsForNonTurnVideo()) { if (
canUsePtzFeature(socket) &&
!shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() })
) {
/* /*
Verified/VIP users who can queue or claim the camera are PTZ "turn" Verified/VIP users who can queue or claim the camera are PTZ "turn"
participants even before they become operator. When non-turn video is set participants even before they become operator. When non-turn video is set
@@ -1444,18 +1706,16 @@ function registerSocketHandlers() {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
socket.on('ptzCamera:move', async (firstArg, secondArg) => { socket.on('ptzCamera:motion', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg); const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try { try {
cb(await move(socket, payload)); /*
} catch (err) { Acknowledge acceptance of the newest desired state immediately. The
cb({ error: err.message }); serialized ONVIF pump deliberately runs independently of Socket.IO
} request latency so browser heartbeats cannot accumulate while waiting
}); for a camera SOAP response.
socket.on('ptzCamera:stop', async (firstArg, secondArg) => { */
const { cb } = normalizeSocketArgs(firstArg, secondArg); cb(acceptMotionIntent(socket, payload));
try {
cb(await stop(socket));
} catch (err) { } catch (err) {
cb({ error: err.message }); cb({ error: err.message });
} }
@@ -1577,6 +1837,7 @@ module.exports = {
ptzCameraEvents: events, ptzCameraEvents: events,
getPublicState, getPublicState,
getChatTargetForSocket, getChatTargetForSocket,
getParticipantSocketIds,
canSpeakThroughPtz, canSpeakThroughPtz,
speakText, speakText,
canRequestLiveVideo, canRequestLiveVideo,
@@ -61,6 +61,25 @@ function validateSources(list = [], socket = null) {
} }
function getDefaultWebSources(assignment = {}, socket = null) { function getDefaultWebSources(assignment = {}, socket = null) {
/*
PTZ ownership is intentionally tracked outside assignmentService because
taking the camera releases the user's rover assignment. Check the PTZ
service directly so a source-less web replay request, including `rs
replay`, follows the camera currently controlled by that socket just as it
follows an assigned rover below.
isOperator is deliberately stricter than PTZ access or queue membership:
spectators and users waiting for a camera turn must not silently replay a
camera they are not currently operating. Keeping this rule here also makes
every web replay entry point share the same default instead of teaching the
chat-command adapter about PTZ-specific state.
*/
if (ptzCameraService.getPublicState(socket).isOperator) {
const source = ptzCameraService.getReplaySource();
if (!source) return [];
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
}
if (assignment?.roverId) { if (assignment?.roverId) {
const id = String(assignment.roverId); const id = String(assignment.roverId);
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id); const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
+14
View File
@@ -6,6 +6,7 @@ const logger = require('../../globals/logger').child('roverManager');
const { sendAlert } = require('../alertService'); const { sendAlert } = require('../alertService');
const { parseSensorFrame } = require('../../helpers/sensorDecoder'); const { parseSensorFrame } = require('../../helpers/sensorDecoder');
const odometerService = require('../odometerService'); const odometerService = require('../odometerService');
const overcurrentProtectionService = require('../overcurrentProtectionService');
const { MODES, getMode } = require('../modeManager'); const { MODES, getMode } = require('../modeManager');
const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService'); const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
@@ -179,6 +180,7 @@ const sensorPipeline = createSensorPipeline({
sendAlert, sendAlert,
publishEvent, publishEvent,
processOdometerFrame: odometerService.processSensorFrame, processOdometerFrame: odometerService.processSensorFrame,
processOvercurrentTelemetry: overcurrentProtectionService.processTelemetry,
isPrivateRecord, isPrivateRecord,
isPrivateOpen, isPrivateOpen,
getPrivateSafety, getPrivateSafety,
@@ -190,6 +192,18 @@ const sensorPipeline = createSensorPipeline({
const { handleSensorFrame, applyPrivateDriveSafety } = sensorPipeline; const { handleSensorFrame, applyPrivateDriveSafety } = sensorPipeline;
stopDockGuard = sensorPipeline.stopDockGuard; stopDockGuard = sensorPipeline.stopDockGuard;
managerEvents.on('rover', ({ roverId, action }) => {
/*
Protection state contains the last motor intent for a specific physical
rover connection. Removing it with the roster record prevents a reconnect
from inheriting stale stress, an old administrator bypass, or a neutral
requirement from the previous connection.
*/
if (action === 'removed') {
overcurrentProtectionService.cleanupRover(roverId);
}
});
function removeSocket(socket) { function removeSocket(socket) {
roverLifecycle.removeSocket(socket, disableSpectator); roverLifecycle.removeSocket(socket, disableSpectator);
} }
@@ -31,6 +31,7 @@ function createSensorPipeline(deps) {
sendAlert, sendAlert,
publishEvent, publishEvent,
processOdometerFrame, processOdometerFrame,
processOvercurrentTelemetry,
isPrivateRecord, isPrivateRecord,
isPrivateOpen, isPrivateOpen,
getPrivateSafety, getPrivateSafety,
@@ -551,6 +552,19 @@ function createSensorPipeline(deps) {
}; };
record.lastSensor = { raw: frame, decoded }; record.lastSensor = { raw: frame, decoded };
} }
/*
Rover manager remains responsible only for decoding and routing sensor
frames. The dedicated service receives the completed sensor object after
odometry has added measured wheel speeds, because requested-versus-actual
motion is the evidence that distinguishes a transient current spike from
a mechanically stalled wheel.
*/
// Use server arrival time inside the service rather than the Pi timestamp.
// Raspberry Pi clocks can differ across the fleet, while command resend
// throttling is also measured on this server and needs one clock domain.
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
? processOvercurrentTelemetry(roverId, decoded)
: null;
updateMovement(record, decoded); updateMovement(record, decoded);
const hasDockInfo = decoded?.chargingSources != null; const hasDockInfo = decoded?.chargingSources != null;
if (hasDockInfo) { if (hasDockInfo) {
@@ -563,8 +577,18 @@ function createSensorPipeline(deps) {
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now(); if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
handlePrivateButtonHold(record, decoded); handlePrivateButtonHold(record, decoded);
evaluatePrivateSafety(record, decoded); evaluatePrivateSafety(record, decoded);
io.to(record.room).volatile.emit('sensorFrame', { roverId, frame, sensors: decoded }); io.to(record.room).volatile.emit('sensorFrame', {
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState }); roverId,
frame,
sensors: decoded,
overcurrentProtection,
});
managerEvents.emit('sensor', {
roverId,
sensors: decoded,
batteryState: record.batteryState,
overcurrentProtection,
});
evaluateDockGuard(record, decoded); evaluateDockGuard(record, decoded);
} }
+46 -6
View File
@@ -20,6 +20,10 @@ const { getState: getHomeAssistantState, homeAssistantEvents } = require('../hom
const { getState: getNeatoState, neatoEvents } = require('../neatoService'); const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService'); const { getState: getLiftState, liftEvents } = require('../liftService');
const { getState: getKinectState, kinectEvents } = require('../kinectService'); const { getState: getKinectState, kinectEvents } = require('../kinectService');
const {
getState: getBalanceBoardState,
balanceBoardEvents,
} = require('../balanceBoardService');
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService'); const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
const { getNickname, nicknameEvents } = require('../nicknameService'); const { getNickname, nicknameEvents } = require('../nicknameService');
const { const {
@@ -42,6 +46,7 @@ const { getFeatureFlags } = require('../../helpers/features');
const { const {
canUseExternalSpectatorAccess, canUseExternalSpectatorAccess,
getBandwidthSavingsPolicy, getBandwidthSavingsPolicy,
shouldUseSnapshotsForNonTurnVideo,
} = require('../../helpers/bandwidthSavings'); } = require('../../helpers/bandwidthSavings');
const { const {
getFeatureState, getFeatureState,
@@ -79,12 +84,17 @@ function hasExternalSpectatorGrant(socket) {
return Boolean(state?.external); return Boolean(state?.external);
} }
function buildBandwidthSavingsSessionState(socket) { function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
const policy = getBandwidthSavingsPolicy(); const policy = getBandwidthSavingsPolicy();
const local = isLocalNetwork(getSocketIp(socket)); const local = isLocalNetwork(getSocketIp(socket));
const granted = hasExternalSpectatorGrant(socket); const granted = hasExternalSpectatorGrant(socket);
return { return {
...policy, ...policy,
nonTurnVideo: {
...policy.nonTurnVideo,
controllableUserCount,
snapshotsActive: shouldUseSnapshotsForNonTurnVideo({ controllableUserCount }),
},
/* /*
These derived fields let browser routes make clear UI choices without These derived fields let browser routes make clear UI choices without
re-implementing IP/admin/grant logic. The server still enforces the same re-implementing IP/admin/grant logic. The server still enforces the same
@@ -100,6 +110,24 @@ function buildBandwidthSavingsSessionState(socket) {
}; };
} }
function countControllableUsers(userEntries = []) {
const ids = new Set();
userEntries.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const socketId = String(entry?.socketId || '').trim();
const roverId = String(entry?.roverId || '').trim();
/*
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
id and normal drivers to their physical rover. Counting entries after that
normalization gives the browser the same conceptual "controllable users"
count it shows in the user/queue panels without duplicating PTZ UI logic.
*/
if (socketId && roverId) ids.add(socketId);
});
return ids.size;
}
function buildUserEntry(socket) { function buildUserEntry(socket) {
if (!socket) return null; if (!socket) return null;
const role = getRole(socket); const role = getRole(socket);
@@ -124,10 +152,11 @@ function buildUserEntry(socket) {
function buildSession(socket) { function buildSession(socket) {
const overseerVote = getOverseerVoteStatus(); const overseerVote = getOverseerVoteStatus();
const features = getFeatureFlags(); const features = getFeatureFlags();
const users = Array.from(io.sockets.sockets.values()) const userEntries = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock)) .map((sock) => buildUserEntry(sock))
.filter(Boolean) .filter(Boolean);
.map((entry) => ({ const controllableUserCount = countControllableUsers(userEntries);
const users = userEntries.map((entry) => ({
...entry, ...entry,
/* /*
PTZ is intentionally not a roverManager record, so the normal physical PTZ is intentionally not a roverManager record, so the normal physical
@@ -135,7 +164,9 @@ function buildSession(socket) {
it here because getPtzChatTargetForSocket already applied the PTZ access it here because getPtzChatTargetForSocket already applied the PTZ access
and queue/operator rules before buildUserEntry returned it. and queue/operator rules before buildUserEntry returned it.
*/ */
roverId: entry.roverId === PTZ_CAMERA_ID ? entry.roverId : filterVisibleRoverId(socket, entry.roverId), roverId: entry.roverId === PTZ_CAMERA_ID
? entry.roverId
: filterVisibleRoverId(socket, entry.roverId),
})); }));
const roster = roverManager.getRosterForSocket(socket); const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || ''); const assignment = assignmentService.describeAssignment(socket?.id || '');
@@ -148,7 +179,7 @@ function buildSession(socket) {
role: getRole(socket), role: getRole(socket),
mode: getMode(), mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)), isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket), bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
/* /*
Features is the single UI contract for optional server capabilities. A Features is the single UI contract for optional server capabilities. A
disabled feature should be absent from navigation/layout decisions even disabled feature should be absent from navigation/layout decisions even
@@ -170,6 +201,7 @@ function buildSession(socket) {
neato: getNeatoState(), neato: getNeatoState(),
lift: getLiftState(), lift: getLiftState(),
kinect: getKinectState(), kinect: getKinectState(),
balanceBoard: getBalanceBoardState(),
replay: getReplayState(), replay: getReplayState(),
replaySources: getReplaySources(socket), replaySources: getReplaySources(socket),
health: getHealthSnapshot(), health: getHealthSnapshot(),
@@ -370,6 +402,14 @@ kinectEvents.on('change', () => {
syncAll(); syncAll();
}); });
balanceBoardEvents.on('change', () => {
// Live weight frames use their own Socket.IO room because they change much
// faster than the full session. Only connection/status changes reach this
// listener, keeping session sync inexpensive while the panel stays current.
logger.info('Balance Board state change; syncing all clients');
syncAll();
});
replayEvents.on('update', () => { replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients'); logger.info('Replay cooldown updated; syncing all clients');
syncAll(); syncAll();
+21
View File
@@ -196,6 +196,26 @@ function canDrive(roverId, socket) {
return activeDrivers.get(roverId) === socket.id; return activeDrivers.get(roverId) === socket.id;
} }
function canRequestLiveVideo(roverId, socket) {
if (!socket) return false;
if (canDrive(roverId, socket)) return true;
const queue = driverQueues.get(roverId);
if (!queue || getMode() !== MODES.TURNS) {
return false;
}
/*
This helper is intentionally broader than canDrive(). Bandwidth saving is a
presentation/subscription decision for normal driver clients: the UI keeps
non-current drivers on snapshots, and only asks for live video when it wants
to warm or show the stream. The server should still verify that the socket is
actually attached to this rover, but it should not reject a legitimate queued
driver because the browser and turn timer are a few milliseconds out of sync.
*/
return queue.queue.includes(socket.id);
}
function isQueuedDriver(roverId, socketId) { function isQueuedDriver(roverId, socketId) {
if (!socketId) return false; if (!socketId) return false;
const queue = driverQueues.get(roverId); const queue = driverQueues.get(roverId);
@@ -410,6 +430,7 @@ module.exports = {
driverRemoved, driverRemoved,
cleanupRover, cleanupRover,
canDrive, canDrive,
canRequestLiveVideo,
isQueuedDriver, isQueuedDriver,
getActiveDrivers, getActiveDrivers,
turnEvents, turnEvents,
@@ -30,6 +30,7 @@ const { canAccessStream } = createVideoAuthPolicy({
ptzCameraService, ptzCameraService,
getSocketIp, getSocketIp,
isLocalNetwork, isLocalNetwork,
io,
}); });
registerVideoAuthRoute({ registerVideoAuthRoute({
+30 -3
View File
@@ -19,8 +19,31 @@ function createVideoAuthPolicy(deps) {
ptzCameraService, ptzCameraService,
getSocketIp, getSocketIp,
isLocalNetwork, isLocalNetwork,
io,
} = deps; } = deps;
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
/*
MediaMTX can ask for authorization after a browser has already received
a token, so this count intentionally mirrors videoSocketService instead
of trusting the client-visible session policy snapshot.
*/
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
if (typeof ptzCameraService.getParticipantSocketIds === 'function') {
ptzCameraService.getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
}
return ids.size;
}
function canView(socket) { function canView(socket) {
const mode = getMode(); const mode = getMode();
if (!socket) return false; if (!socket) return false;
@@ -78,11 +101,15 @@ function createVideoAuthPolicy(deps) {
if (!roverManager.isDriver(roverId, socket)) { if (!roverManager.isDriver(roverId, socket)) {
return false; return false;
} }
if (!isAudio && shouldUseSnapshotsForNonTurnVideo() && !turnService.canDrive(roverId, socket)) { if (
!isAudio &&
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canRequestLiveVideo(roverId, socket)
) {
/* /*
This mirrors videoSocketService's token gate. MediaMTX can ask auth This mirrors videoSocketService's token gate. MediaMTX can ask auth
after a token has been issued, so the active-turn bandwidth rule must after a token has been issued, so the same "must belong to this rover's
be evaluated here too instead of trusting an older browser decision. driver queue" rule has to be evaluated here too.
*/ */
return false; return false;
} }
@@ -85,6 +85,29 @@ function canViewRoomCamera(socket) {
return passesMode(socket); return passesMode(socket);
} }
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
/*
Rover drivers and PTZ participants are both "controllable" users for this
bandwidth decision because either group can create a non-turn video view.
Counting unique socket ids prevents someone who is transitioning between
rover and PTZ from being counted twice.
*/
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
if (typeof ptzCameraService.getParticipantSocketIds === 'function') {
ptzCameraService.getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
}
return ids.size;
}
function normalizeRequest(payload = {}) { function normalizeRequest(payload = {}) {
if (!payload) return null; if (!payload) return null;
if (payload.type && payload.id) { if (payload.type && payload.id) {
@@ -129,16 +152,16 @@ io.on('connection', (socket) => {
!isAudio && !isAudio &&
role !== 'spectator' && role !== 'spectator' &&
!isAdmin(socket) && !isAdmin(socket) &&
shouldUseSnapshotsForNonTurnVideo() && shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canDrive(baseId, socket) !turnService.canRequestLiveVideo(baseId, socket)
) { ) {
/* /*
The browser also forces snapshots for non-active turn holders, but The browser owns the snapshot-vs-live presentation for queued rover
the socket token path must enforce the same rule. Otherwise a stale drivers. The server side only verifies that the socket belongs to
component or direct socket caller could still mint a MediaMTX token this rover's driver queue so legitimate warm-up/switch requests are
while the UI is showing snapshots. not rejected by small turn-timer timing differences.
*/ */
throw new Error('Live video is limited to the active turn'); throw new Error('Live video is limited to this rover queue');
} }
} else if (target.type === 'room') { } else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed'); throw new Error('Room cameras now use the snapshot feed');
+10 -3
View File
@@ -1,8 +1,15 @@
1. assign rovers based on battery percentage, give people highest one 1. assign rovers based on battery percentage, give people highest one
2. add admin ui for VIP and private requests instead of only through discord 2. add admin ui for VIP and private requests instead of only through discord
3. make overcurrent limiter speed sensitive, slower fill at lower speeds 3. add flag in roverd for video aspect ratio
4. add more background gap themes 1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
5. fix this: 1. just removing the black bars, doesnt do anything practical for the driver page
2. would only actually help for keeping spectate page compact
1. maybe just make the spectate videos be fixed width and match the height of the media
2. either 4:3 or 16:9
3. default is 4:3
4. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
4. 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]: ^
+15 -31
View File
@@ -15,6 +15,7 @@ import {
} from './controls/index.js'; } from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx'; import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
import KinectPanel from './components/KinectPanel/index.jsx'; import KinectPanel from './components/KinectPanel/index.jsx';
import BalanceBoardPanel from './components/BalanceBoardPanel/index.jsx';
import DriverVideo from './components/DriverVideo/index.jsx'; import DriverVideo from './components/DriverVideo/index.jsx';
import RightPaneTabs from './components/RightPaneTabs/index.jsx'; import RightPaneTabs from './components/RightPaneTabs/index.jsx';
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx'; import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
@@ -50,38 +51,14 @@ import NeatoCard from './components/NeatoCard/index.jsx';
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx'; import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx'; import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx'; import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js'; import {
DEFAULT_PAGE_THEME_KEY,
getPageThemeClass,
themeGapClass,
themeStackClass,
} from './themes/index.js';
import { trackAnalyticsEvent } from './analytics/index.js'; import { trackAnalyticsEvent } from './analytics/index.js';
import useLayoutMode from './hooks/useLayoutMode.js';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
if (typeof window === 'undefined') return 'desktop';
return window.innerWidth >= 1024
? 'desktop'
: window.innerWidth > window.innerHeight
? 'mobile-landscape'
: 'mobile-portrait';
});
useEffect(() => {
function updateMode() {
if (typeof window === 'undefined') return;
const { innerWidth, innerHeight } = window;
if (innerWidth >= 1024) {
setMode('desktop');
} else if (innerWidth > innerHeight) {
setMode('mobile-landscape');
} else {
setMode('mobile-portrait');
}
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
function DesktopLayout({ layout, onOpenHelpOverlay }) { function DesktopLayout({ layout, onOpenHelpOverlay }) {
return ( return (
@@ -200,6 +177,7 @@ function MobileFeatureTabs({
<div className={`flex flex-col ${themeGapClass}`}> <div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard /> <NeatoCard />
<LiftCard /> <LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel /> <BarcodeGamesPanel />
<OdometerPanel /> <OdometerPanel />
<ButtonBoxPanel /> <ButtonBoxPanel />
@@ -307,6 +285,12 @@ function App() {
const layout = useLayoutMode(); const layout = useLayoutMode();
const isDesktop = layout === 'desktop'; const isDesktop = layout === 'desktop';
const fullscreen = useFullscreenPrompt(layout); const fullscreen = useFullscreenPrompt(layout);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
// Resolve the cookie value through the shared catalog before painting the page. This prevents
// an obsolete or hand-edited key from stripping the background class from every exposed seam.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
return ( return (
<div className={`${pageBackgroundClass} text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'ios-safe-screen min-h-screen'}`}> <div className={`${pageBackgroundClass} text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'ios-safe-screen min-h-screen'}`}>
@@ -0,0 +1,269 @@
// Balance Board Panel
// Purpose: Shows exactly what the Bluetooth board is doing and its current total weight.
// Scope: Owns optional feature gating and the live weight-frame subscription only.
import { useEffect, useState } from 'react';
import { useSocket } from '../../context/SocketContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import CardFrame from '../CardFrame/index.jsx';
const EMPTY_CORNERS = {
topLeft: 0,
topRight: 0,
bottomLeft: 0,
bottomRight: 0,
};
const EMPTY_FRAME = {
totalKg: 0,
batteryPercent: null,
// Null distinguishes "no live frame received yet" from a legitimate record
// of zero, allowing the persisted session value to remain visible while the
// socket room subscription is being established.
recordKg: null,
recordedAt: null,
corners: EMPTY_CORNERS,
};
function formatWeight(value) {
const weight = Number(value);
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
}
function finiteNumber(value) {
if (value == null || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function centerOfPressure(corners) {
const topLeft = Math.max(0, finiteNumber(corners.topLeft) || 0);
const topRight = Math.max(0, finiteNumber(corners.topRight) || 0);
const bottomLeft = Math.max(0, finiteNumber(corners.bottomLeft) || 0);
const bottomRight = Math.max(0, finiteNumber(corners.bottomRight) || 0);
const total = topLeft + topRight + bottomLeft + bottomRight;
// Only an exact zero stays centered because dividing by zero cannot produce a
// position. Every positive reading participates immediately, with no minimum
// weight or center deadzone hiding small shifts reported by the load cells.
if (total === 0) return { left: 50, top: 50, active: false };
const horizontal = ((topRight + bottomRight) - (topLeft + bottomLeft)) / total;
const vertical = ((bottomLeft + bottomRight) - (topLeft + topRight)) / total;
return {
left: 50 + Math.max(-1, Math.min(1, horizontal)) * 37,
top: 50 + Math.max(-1, Math.min(1, vertical)) * 37,
active: true,
};
}
function CornerReading({ className, label, value }) {
return (
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
<div className="text-[0.62rem] text-slate-400">{label}</div>
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
);
}
export default function BalanceBoardPanel() {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'balanceBoard'));
// Keep feature ownership inside the component so layouts do not need special
// cases or empty wrappers when the optional hardware is disabled.
if (!enabled) return null;
return <BalanceBoardPanelContent />;
}
function BalanceBoardPanelContent() {
const socket = useSocket();
const board = useSessionSelector((state) => state.session?.balanceBoard || null);
const role = useSessionSelector((state) => state.session?.role || null);
const [frame, setFrame] = useState(EMPTY_FRAME);
const [unpairing, setUnpairing] = useState(false);
const [zeroRequesting, setZeroRequesting] = useState(false);
const [resettingRecord, setResettingRecord] = useState(false);
useEffect(() => {
if (!socket) return undefined;
const handleFrame = (next = {}) => setFrame({ ...EMPTY_FRAME, ...next });
// Socket.IO room membership belongs to one server-side connection, not to
// the long-lived browser socket object. A brief network interruption gives
// the browser a new server-side socket while React keeps this component and
// this effect mounted, so subscribing only here would silently lose all
// later weight frames. Rejoin after every connection as well as immediately
// for the already-connected case.
const subscribe = () => {
socket.emit('balanceBoard:subscribe', {}, () => {});
};
socket.on('balanceBoard:frame', handleFrame);
socket.on('connect', subscribe);
subscribe();
return () => {
socket.off('balanceBoard:frame', handleFrame);
socket.off('connect', subscribe);
// The panel is the only consumer represented by this component. Leaving
// the room on unmount prevents an inactive route or tab from continuing
// to receive the board's continuous measurement stream.
socket.emit('balanceBoard:unsubscribe');
};
}, [socket]);
// Mask the previous reading immediately when disconnected. Keeping the last
// socket frame in state avoids effect-driven state resets and stale flashes.
const liveFrame = board?.connected ? frame : EMPTY_FRAME;
const corners = { ...EMPTY_CORNERS, ...(liveFrame.corners || {}) };
const center = centerOfPressure(corners);
const liveBattery = finiteNumber(liveFrame.batteryPercent);
const sessionBattery = finiteNumber(board?.batteryPercent);
const battery = liveBattery ?? sessionBattery;
// Live frames make a newly reached record move immediately. The session copy
// remains available while the board sleeps or before this panel subscribes,
// which is important because the record belongs to the installation rather
// than to one Bluetooth connection.
const liveRecord = board?.connected ? finiteNumber(frame.recordKg) : null;
const sessionRecord = finiteNumber(board?.recordKg);
const record = liveRecord ?? sessionRecord ?? 0;
const sleeping = board?.status === 'sleeping';
const isAdmin = role === 'admin' || role === 'lockdown';
const calibration = board?.calibration || null;
const zeroing = Boolean(calibration?.active);
const zero = () => {
if (zeroRequesting || zeroing || !board?.connected) return;
if (!window.confirm('Use the boards current load as zero? Keep everything still for ten seconds.')) return;
setZeroRequesting(true);
socket.emit('balanceBoard:zero', {}, (response = {}) => {
setZeroRequesting(false);
if (response.error) window.alert(response.error);
});
};
const unpair = () => {
if (unpairing || !board?.paired) return;
if (!window.confirm('Unpair this Balance Board and require the red Sync button to pair it again?')) return;
setUnpairing(true);
socket.emit('balanceBoard:unpair', {}, (response = {}) => {
setUnpairing(false);
if (response.error) {
window.alert(response.error);
} else if (response.warning) {
window.alert('Board forgotten locally, but BlueZ reported a bond-removal warning.');
}
});
};
const resetRecord = () => {
if (resettingRecord) return;
if (!window.confirm('Reset the highest weight record?')) return;
setResettingRecord(true);
socket.emit('balanceBoard:resetRecord', {}, (response = {}) => {
setResettingRecord(false);
if (response.error) window.alert(response.error);
});
};
const actions = isAdmin ? (
<div className="flex items-center gap-0.5">
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={!board?.connected || zeroRequesting || zeroing || unpairing}
onClick={zero}
>
{zeroing
? `Zeroing ${calibration.samplesCollected}/${calibration.totalSamples}`
: zeroRequesting ? 'Starting…' : 'Zero'}
</button>
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={!board?.paired || unpairing || zeroing}
onClick={unpair}
>
{unpairing ? 'Unpairing…' : 'Unpair'}
</button>
</div>
) : null;
return (
<CardFrame
title="Balance Board"
className="relative w-full"
bodyClassName="text-sm text-slate-200"
actions={actions}
>
{sleeping ? (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/85 px-2 text-center">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-slate-100">The Balance Board is asleep</p>
<p className="text-sm text-slate-300">Press the front power button on the board to wake it.</p>
</div>
</div>
) : null}
{/* Keep the measurement column narrow and fixed so the board remains the
dominant visual while record and battery stay in one predictable
place. Both pieces use the shared dark panel treatment instead of
introducing a Balance Board-specific background style. */}
<div className="grid grid-cols-[minmax(0,1fr)_8rem] gap-0.5">
<div className="panel-section relative h-52 overflow-hidden">
{zeroing ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-neutral-950/90 px-2 text-center">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-slate-100">
Zeroing {calibration.samplesCollected}/{calibration.totalSamples}
</p>
<p className="text-sm text-slate-300">Keep the board and everything on it still.</p>
</div>
</div>
) : null}
<CornerReading className="left-0.5 top-0.5" label="Top left" value={corners.topLeft} />
<CornerReading className="right-0.5 top-0.5" label="Top right" value={corners.topRight} />
<CornerReading className="bottom-0.5 left-0.5" label="Bottom left" value={corners.bottomLeft} />
<CornerReading className="bottom-0.5 right-0.5" label="Bottom right" value={corners.bottomRight} />
<div
aria-label="Center of pressure"
className={`absolute z-10 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border transition-all duration-100 ${
center.active
? 'border-sky-200 bg-sky-500'
: 'border-neutral-500 bg-neutral-600 opacity-50'
}`}
style={{ left: `${center.left}%`, top: `${center.top}%` }}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="surface px-1 py-0.5 text-center">
<div className="text-[0.65rem] text-slate-400">Total weight</div>
<div className="text-3xl font-bold leading-none text-white">
{formatWeight(liveFrame.totalKg)}
</div>
</div>
</div>
</div>
<div className="grid h-52 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
<div className="panel-section flex min-h-0 flex-col items-center justify-center gap-1 text-center">
<div className="text-xs text-slate-400">Weight record</div>
<div className="text-xl font-bold text-white">{formatWeight(record)}</div>
{isAdmin ? (
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={resettingRecord}
onClick={resetRecord}
>
{resettingRecord ? 'Resetting…' : 'Reset'}
</button>
) : null}
</div>
<div className="panel-section px-1 py-1 text-center">
<div className="text-xs text-slate-400">Battery</div>
<div className="text-xl font-semibold text-slate-100">
{battery == null ? '—' : `${Math.round(battery)}%`}
</div>
</div>
</div>
</div>
</CardFrame>
);
}
+15
View File
@@ -6,6 +6,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx'; import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import useChatMessageHistoryNavigation from '../../hooks/useChatMessageHistoryNavigation.js';
import ChatMessageRow from '../ChatMessageRow/index.jsx'; import ChatMessageRow from '../ChatMessageRow/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
@@ -260,6 +261,7 @@ function ChatComposer({
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(true); const [speak, setSpeak] = useState(true);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const effectiveSpeak = ttsSupported && speak; const effectiveSpeak = ttsSupported && speak;
const ttsPayload = useMemo(() => { const ttsPayload = useMemo(() => {
if (!effectiveSpeak) return null; if (!effectiveSpeak) return null;
@@ -291,6 +293,7 @@ function ChatComposer({
try { try {
await sendMessage(clean, ttsPayload); await sendMessage(clean, ttsPayload);
setDraft(''); setDraft('');
resetHistoryNavigation();
blurChat(); blurChat();
setTypingActive(false); setTypingActive(false);
} catch (err) { } catch (err) {
@@ -314,6 +317,9 @@ function ChatComposer({
value={draft} value={draft}
onChange={(event) => { onChange={(event) => {
const next = event.target.value; const next = event.target.value;
// A direct edit starts a fresh history traversal. This prevents an
// old ArrowDown position from overwriting text the user just typed.
resetHistoryNavigation();
setDraft(next); setDraft(next);
setTypingActive(Boolean(next.trim())); setTypingActive(Boolean(next.trim()));
}} }}
@@ -326,6 +332,15 @@ function ChatComposer({
setTypingActive(false); setTypingActive(false);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
event.preventDefault();
setDraft(recalledDraft);
setTypingActive(Boolean(recalledDraft.trim()));
}
return;
}
if (event.key === 'Enter' && !draft.trim()) { if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault(); event.preventDefault();
blurChat(); blurChat();
@@ -5,6 +5,7 @@ import { memo, useMemo, useState } from 'react';
import { useChatActions } from '../../../context/ChatContext.jsx'; import { useChatActions } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js'; import { useSettingsNamespace } from '../../../settings/index.js';
import useChatMessageHistoryNavigation from '../../../hooks/useChatMessageHistoryNavigation.js';
function detectSafari() { function detectSafari() {
if (typeof navigator === 'undefined') return false; if (typeof navigator === 'undefined') return false;
@@ -42,6 +43,7 @@ function HudChatInput({ compact = false }) {
}); });
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator'; const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator'; const hideHudChat = role === 'spectator';
const chatTargetId = useMemo(() => { const chatTargetId = useMemo(() => {
@@ -118,6 +120,7 @@ function HudChatInput({ compact = false }) {
try { try {
await sendMessage(clean, ttsPayload); await sendMessage(clean, ttsPayload);
setDraft(''); setDraft('');
resetHistoryNavigation();
blurChat(); blurChat();
setTypingActive(false); setTypingActive(false);
} catch (err) { } catch (err) {
@@ -136,6 +139,9 @@ function HudChatInput({ compact = false }) {
value={draft} value={draft}
onChange={(event) => { onChange={(event) => {
const next = event.target.value; const next = event.target.value;
// Keep HUD navigation independent from the panel's cursor even
// though both inputs read the same persisted message collection.
resetHistoryNavigation();
setDraft(next); setDraft(next);
setTypingActive(Boolean(next.trim())); setTypingActive(Boolean(next.trim()));
}} }}
@@ -148,6 +154,15 @@ function HudChatInput({ compact = false }) {
setTypingActive(false); setTypingActive(false);
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
event.preventDefault();
setDraft(recalledDraft);
setTypingActive(Boolean(recalledDraft.trim()));
}
return;
}
if (event.key === 'Enter' && !draft.trim()) { if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault(); event.preventDefault();
blurChat(); blurChat();
@@ -1,63 +1,74 @@
// Overcurrent Overlay // Overcurrent Overlay
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file. // Purpose: Shows server-authoritative motor limiting, stop, recovery, and administrator-bypass status.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Renders protection state only; it never calculates stress or changes motor commands.
import React from 'react';
import { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../../../context/telemetryViews.js';
import { useOvercurrentLimiter } from '../../../controls/index.js'; import { useOvercurrentLimiter } from '../../../controls/index.js';
import { OVERCURRENT_LABELS } from './constants.js'; import { OVERCURRENT_LABELS } from './constants.js';
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) { function OvercurrentOverlay({ roverId = null, overcurrentLimiter = null, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId; const effectiveRoverId = roverId ?? assignedRoverId;
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
const internalLimiter = useOvercurrentLimiter(effectiveRoverId); const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents; const protection = overcurrentLimiter ?? internalLimiter;
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null; const status = protection?.status || 'idle';
const overcurrentMotors = useMemo( const motors = protection?.motors || {};
() => const activeMotors = useMemo(
resolvedOvercurrents == null () => Object.entries(motors)
? [] .filter(([, motor]) => Boolean(motor?.overcurrent) || Number(motor?.stress) > 0)
: Object.entries(resolvedOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key), .map(([key]) => key),
[resolvedOvercurrents], [motors],
); );
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
const limiterFill = useMemo(() => {
if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
const motors = useMemo(
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
[overcurrentMotors, limiterActive],
);
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
if (!motors?.length) return null; if (status === 'idle') return null;
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]'; const stopReason = protection?.drive?.stopReason;
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2'; const displayMotors = stopReason ? [stopReason] : activeMotors;
const textClass = compact ? 'text-lg' : 'text-4xl'; const labels = displayMotors.map((name) => OVERCURRENT_LABELS[name] || name);
const subTextClass = compact ? 'text-xs' : 'text-xl'; const highestStress = displayMotors.reduce(
const safeFill = Math.max(0, Math.min(1, fill)); (highest, name) => Math.max(highest, Number(motors?.[name]?.stress) || 0),
const fillWidth = `${Math.round(safeFill * 100)}%`; 0,
);
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
const fillWidth = `${Math.round(Math.max(0, Math.min(1, highestStress)) * 100)}%`;
const bypassed = status === 'bypassed';
const stopped = status === 'stopped';
const title = bypassed
? 'Overcurrent detected'
: stopped
? 'Drive stopped'
: status === 'recovering'
? 'Protection recovering'
: status === 'limiting'
? 'Overcurrent limiting'
: 'Overcurrent detected';
const detail = bypassed
? 'Admin bypass'
: stopped && protection?.drive?.requiresNeutral
? `${labels.join(', ') || 'Wheel stall'} · release controls to resume`
: status === 'limiting'
? `${labels.join(', ')} · output ${Math.round(driveCap * 100)}%`
: labels.join(', ');
const containerClass = bypassed
? 'h-[3.5rem] w-[14rem]'
: compact
? 'h-[3.5rem] w-[14rem]'
: 'h-[7rem] w-[22rem]';
const titleClass = compact || bypassed ? 'text-base' : 'text-3xl';
const detailClass = compact || bypassed ? 'text-xs' : 'text-base';
const backgroundClass = bypassed ? 'bg-amber-950/75' : 'bg-red-950/70';
const fillClass = bypassed ? 'bg-amber-700/50' : 'bg-red-700/60';
return ( return (
<div <div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`} className={`pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center ${backgroundClass} ${containerClass}`}
> >
<div className="relative h-full w-full"> <div className="relative h-full w-full overflow-hidden">
<div className="absolute inset-0 overflow-hidden"> <div className={`absolute inset-y-0 left-0 ${fillClass}`} style={{ width: fillWidth }} />
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} /> <div className="relative z-10 flex h-full flex-col items-center justify-center px-2 text-center font-semibold text-white">
</div> <div className={titleClass}>{title}</div>
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}> <div className={`font-medium ${detailClass}`}>{detail}</div>
<div>OVERCURRENT</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
// Inter Instance Panel // Inter Instance Panel
// Purpose: Renders remote rover servers discovered through the inter-instance directory. // Purpose: Renders remote rover servers discovered through the inter-instance directory.
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows. // Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx'; import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
@@ -133,26 +133,36 @@ function RemoteMediaStrip({ remote }) {
); );
} }
export function ExternalInstancesCompact() { export function ExternalInstancesCompact({ onBrowse = null }) {
const [expanded, setExpanded] = useState(false);
const [popupOpen, setPopupOpen] = useState(false);
const enabled = useInterInstanceEnabled(); const enabled = useInterInstanceEnabled();
const instances = useRemoteInstances(); const instances = useRemoteInstances();
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]); const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
if (!enabled) return null; if (!enabled) return null;
if (!visible.length) return null; if (!visible.length) return null;
const browseAction = onBrowse ? (
<button type="button" className="button-dark" onClick={onBrowse}>
Browse Servers
</button>
) : null;
return ( return (
<div className="space-y-0.5"> /*
<div className="grid grid-cols-2 gap-0.5"> External instances are intentionally always mounted. Besides removing an
<button type="button" className="button-dark w-full" onClick={() => setExpanded((value) => !value)}> unnecessary disclosure click, this preserves the live queue rows while
{expanded ? 'Hide external' : `Show external (${visible.length})`} the local Rover Queues card can provide one continuous scroll surface for
</button> both its local and external rows. Scrolling belongs to that owning panel,
<button type="button" className="button-dark w-full" onClick={() => setPopupOpen(true)}> so this nested section deliberately keeps its natural content height.
Browse servers */
</button> <CardFrame
</div> title="External servers below:"
{expanded ? ( actions={browseAction}
<div className="space-y-0.5"> bodyClassName="space-y-0.5 text-sm"
>
{/*
One containing card gives the remote-server collection a clear boundary
below the local rover rows. Individual remote queue cards stay intact
inside it because they still own each server's title and operational
status, while this outer title bar owns the collection-wide browser.
*/}
{visible.map((remote) => {visible.map((remote) =>
remote.online ? ( remote.online ? (
<RoverQueuesPanel <RoverQueuesPanel
@@ -168,10 +178,7 @@ export function ExternalInstancesCompact() {
<InstancePanel key={remote.url} remote={remote} /> <InstancePanel key={remote.url} remote={remote} />
), ),
)} )}
</div> </CardFrame>
) : null}
{popupOpen ? <InterInstancePopup onClose={() => setPopupOpen(false)} /> : null}
</div>
); );
} }
@@ -180,8 +187,9 @@ export function InterInstancePopup({ onClose }) {
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5"> <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
<InterInstanceBrowserFrame <InterInstanceBrowserFrame
onClose={onClose} onClose={onClose}
className="max-w-[calc(100vw-0.5rem)]" scaledOverlay
bodyClassName="max-h-[82vh] overflow-y-auto p-0.5" className="inter-instance-overlay-frame"
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
/> />
</div> </div>
); );
@@ -226,6 +234,7 @@ export function InterInstanceBrowserFrame({
className = '', className = '',
bodyClassName = 'p-0.5', bodyClassName = 'p-0.5',
centered = false, centered = false,
scaledOverlay = false,
}) { }) {
const enabled = useInterInstanceEnabled(); const enabled = useInterInstanceEnabled();
const instances = useRemoteInstances(); const instances = useRemoteInstances();
@@ -245,7 +254,7 @@ export function InterInstanceBrowserFrame({
<CardFrame <CardFrame
title="External instances" title="External instances"
actions={actions} actions={actions}
className={className} className={classNames(scaledOverlay && 'inter-instance-overlay-scale', className)}
bodyClassName={bodyClassName} bodyClassName={bodyClassName}
clipOverflow={false} clipOverflow={false}
> >
@@ -148,13 +148,25 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
return () => { return () => {
clearRepeatTimer(); clearRepeatTimer();
/* /*
Mobile controls can unmount when layouts change or the driver leaves the Mobile controls can unmount during an orientation/layout change while a
control surface. Clear the shared flag so a stale mobile precision choice pointer is still captured by the disappearing element. Publish a neutral
cannot leave desktop/keyboard camera tilt in fine-step mode. vector directly during cleanup so neither rover drive nor PTZ pan/tilt
can retain the last cell merely because pointerup had nowhere to land.
*/ */
activeCellRef.current = null;
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
registerInputState(SOURCE, {
keys: [],
vector: { x: 0, y: 0, boost: false },
activeCell: 'stop',
speedMode: speedModeRef.current,
lastEvent: 'unmount',
});
// Clear the shared flag too, so a stale mobile precision choice cannot
// leave desktop/keyboard camera tilt in fine-step mode.
setCameraPrecisionMode(false); setCameraPrecisionMode(false);
}; };
}, [clearRepeatTimer, setCameraPrecisionMode]); }, [clearRepeatTimer, registerInputState, setCameraPrecisionMode, setDriveVector]);
useEffect(() => { useEffect(() => {
if (!disabled) return; if (!disabled) return;
@@ -102,8 +102,9 @@ export default function ModeGateOverlay() {
*/ */
<InterInstanceBrowserFrame <InterInstanceBrowserFrame
hideWhenEmpty hideWhenEmpty
className="max-w-[calc(100vw-0.5rem)]" scaledOverlay
bodyClassName="max-h-[86vh] overflow-y-auto p-0.5" className="inter-instance-overlay-frame"
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
/> />
) : null} ) : null}
</div> </div>
@@ -1,14 +1,15 @@
// Overcurrent Limiter Panel // Overcurrent Protection Panel
// Purpose: Defines the Overcurrent Limiter Panel module and the local helpers/components used in this file. // Purpose: Presents detailed server-calculated motor stress and command-tracking diagnostics.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Read-only status surface for the assigned rover; protection and recovery remain server-owned.
import { useMemo } from 'react';
import { useControlSelector } from '../../controls/index.js'; import { useControlSelector } from '../../controls/index.js';
import { OVERCURRENT_GROUPS } from '../../controls/overcurrentLimiter.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
const GROUP_LABELS = { const MOTOR_LABELS = {
drive: 'Drive wheels', leftWheel: 'Left wheel',
aux: 'Aux motors', rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
}; };
function formatPct(value) { function formatPct(value) {
@@ -16,8 +17,20 @@ function formatPct(value) {
return `${Math.round(value * 100)}%`; return `${Math.round(value * 100)}%`;
} }
function formatSpeed(value) {
if (!Number.isFinite(value)) return '--';
return `${Math.round(value)} mm/s`;
}
function formatClassification(value) {
if (value === 'stalled') return 'Stalled';
if (value === 'partial') return 'Partial';
if (value === 'moving') return 'Moving';
return 'Unknown';
}
function ProgressBar({ value, color = 'bg-emerald-500' }) { function ProgressBar({ value, color = 'bg-emerald-500' }) {
const width = `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`; const width = `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`;
return ( return (
<div className="h-2 w-full overflow-hidden rounded bg-slate-800"> <div className="h-2 w-full overflow-hidden rounded bg-slate-800">
<div className={`h-full ${color}`} style={{ width }} /> <div className={`h-full ${color}`} style={{ width }} />
@@ -25,51 +38,74 @@ function ProgressBar({ value, color = 'bg-emerald-500' }) {
); );
} }
function statusLabel(protection) {
if (protection?.adminImmune) return 'Admin bypass';
if (protection?.status === 'stopped') return 'Drive stopped';
if (protection?.status === 'limiting') return 'Limiting';
if (protection?.status === 'overcurrent') return 'Overcurrent detected';
if (protection?.status === 'recovering') return 'Recovering';
return 'Ready';
}
export default function OvercurrentLimiterPanel() { export default function OvercurrentLimiterPanel() {
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const overcurrentLimiter = useControlSelector((control) => control.overcurrentLimiter); const protection = useControlSelector((control) => control.overcurrentLimiter);
const groups = useMemo(() => OVERCURRENT_GROUPS.map((group) => group.key), []); const motors = protection?.motors || {};
return ( return (
<CardFrame <CardFrame
title="Overcurrent limiter" title="Overcurrent protection"
meta={overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'} meta={statusLabel(protection)}
bodyClassName="space-y-0.5 text-sm" bodyClassName="space-y-1 text-sm"
> >
{!roverId ? ( {!roverId ? (
<p className="text-xs text-slate-500">Assign a rover to view limiter status.</p> <p className="text-xs text-slate-500">Assign a rover to view protection status.</p>
) : ( ) : (
<div className="space-y-0.5"> <div className="space-y-1">
{groups.map((key) => { {Object.entries(MOTOR_LABELS).map(([key, label]) => {
const cap = overcurrentLimiter?.caps?.[key]?.cap ?? 0; const motor = motors[key] || {};
const over = overcurrentLimiter?.overcurrent?.groups?.[key] ?? false; const wheel = key === 'leftWheel' || key === 'rightWheel';
const scale = overcurrentLimiter?.scales?.perGroup?.[key] ?? 1;
return ( return (
<div key={key} className="space-y-0.5"> <div key={key} className="space-y-0.5 border-b border-slate-800 pb-1 last:border-0">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-slate-200">{GROUP_LABELS[key] || key}</span> <span className="text-slate-200">{label}</span>
<span className={over ? 'text-red-300' : 'text-slate-400'}> <span className={motor.overcurrent ? 'text-red-300' : 'text-slate-400'}>
{over ? 'overcurrent' : 'ok'} {motor.overcurrent ? 'Overcurrent' : 'Clear'}
</span> </span>
</div> </div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-[0.7rem] text-slate-400"> <div className="flex items-center justify-between text-[0.7rem] text-slate-400">
<span>Cap</span> <span>Stress {formatPct(motor.stress)}</span>
<span>{formatPct(cap)}</span> <span>Output {formatPct(motor.cap)}</span>
</div>
<ProgressBar value={cap} color="bg-amber-500" />
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
<span>Scale</span>
<span>{formatPct(scale)}</span>
</div> </div>
<ProgressBar
value={motor.stress}
color={motor.overcurrent ? 'bg-red-500' : 'bg-amber-500'}
/>
{wheel ? (
<div className="grid grid-cols-2 gap-1 text-[0.65rem] text-slate-500">
<span>{`Command ${formatSpeed(Math.abs(Number(motor.commandedSpeed)))}`}</span>
<span>{`Measured ${formatSpeed(motor.measuredSpeed)}`}</span>
<span>{`Progress ${formatPct(motor.progressRatio)}`}</span>
<span>{`${formatClassification(motor.classification)} · stall ${formatPct(motor.stallFactor)}`}</span>
</div> </div>
) : null}
</div> </div>
); );
})} })}
{protection?.drive?.blocked ? (
<p className="text-xs text-red-300">
{protection.drive.requiresNeutral
? 'Drive is stopped. Release controls to neutral before resuming.'
: 'Drive is stopped while the wheel condition clears.'}
</p>
) : null}
<div className="text-[0.7rem] text-slate-400"> <div className="text-[0.7rem] text-slate-400">
<div>{`Down rate ${overcurrentLimiter?.config?.downRatePerSec}/s · Up rate ${overcurrentLimiter?.config?.upRatePerSec}/s`}</div> <div>{`Drive output ${formatPct(protection?.drive?.cap)}`}</div>
<div>{`Release delay ${overcurrentLimiter?.config?.releaseDelaySec}s`}</div> <div>
<div>{`Output rate ${overcurrentLimiter?.config?.outputRateMs}ms`}</div> {protection?.adminImmune
? 'This session bypasses all overcurrent enforcement.'
: 'Status and output limits are calculated by the server.'}
</div>
</div> </div>
</div> </div>
)} )}
@@ -35,6 +35,8 @@ const PLACEHOLDER_STATS = Object.freeze({
qualityMax: 70, qualityMax: 70,
rxBitrateMbit: 72.2, rxBitrateMbit: 72.2,
txBitrateMbit: 58.5, txBitrateMbit: 58.5,
downloadMbps: 12.4,
uploadMbps: 3.7,
rxBytes: 12400000, rxBytes: 12400000,
txBytes: 2300000, txBytes: 2300000,
rxPackets: 12640, rxPackets: 12640,
@@ -304,6 +306,7 @@ export default function PiHostStatsCard() {
<section className="min-w-0 space-y-0.5"> <section className="min-w-0 space-y-0.5">
<ColumnTitle label="WiFi" /> <ColumnTitle label="WiFi" />
<div className="grid min-w-0 grid-cols-2 gap-0.5">
<div className="surface grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-1"> <div className="surface grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-1">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div> <div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div>
@@ -314,6 +317,13 @@ export default function PiHostStatsCard() {
<SignalBars bars={bars} tone={currentSignalTone} /> <SignalBars bars={bars} tone={currentSignalTone} />
</div> </div>
</div> </div>
<div className="surface flex min-w-0 flex-col justify-center gap-0.5 text-xs">
{/* Actual traffic belongs beside connection identity and signal,
while negotiated link rates remain in their existing rows. */}
<ThroughputRow label="Download" value={formatBitrate(wifi.downloadMbps)} />
<ThroughputRow label="Upload" value={formatBitrate(wifi.uploadMbps)} />
</div>
</div>
<BarRow <BarRow
label="Link Quality" label="Link Quality"
value={wifi.quality == null ? '--' : `${wifi.quality}/${valueOrDash(wifi.qualityMax)}`} value={wifi.quality == null ? '--' : `${wifi.quality}/${valueOrDash(wifi.qualityMax)}`}
@@ -347,6 +357,15 @@ function StatRow({ label, value, compact = false, valueClassName = 'text-slate-1
); );
} }
function ThroughputRow({ label, value }) {
return (
<div className="flex min-w-0 items-center justify-between gap-1">
<span className="shrink-0 text-slate-400">{label}</span>
<span className="min-w-0 truncate text-right text-slate-100">{value}</span>
</div>
);
}
function BarRow({ label, value, detail = null, percent, tone = 'neutral' }) { function BarRow({ label, value, detail = null, percent, tone = 'neutral' }) {
return ( return (
<div className="surface"> <div className="surface">
+268 -127
View File
@@ -1,14 +1,16 @@
// PTZ Camera UI // PTZ Camera UI
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a // Purpose: Integrates the single PTZ camera into the main rover UI flow as a
// queueable controllable target instead of a VIP-panel card. // queueable controllable target instead of a VIP-panel card.
// Scope: Owns PTZ entry card and fullscreen composition; PTZ command authority, // Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
// queue ownership, and stream authorization remain server-owned. // composition; PTZ command authority, queue ownership, and stream authorization
// remain server-owned.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx'; import ChatPanel from '../ChatPanel/index.jsx';
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx'; import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx'; import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx'; import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx'; import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx'; import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
@@ -21,6 +23,8 @@ import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js'; import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
import { trackAnalyticsEvent } from '../../analytics/index.js'; import { trackAnalyticsEvent } from '../../analytics/index.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
const PTZ_DEFAULT_COLOR = '#38bdf8'; const PTZ_DEFAULT_COLOR = '#38bdf8';
@@ -116,45 +120,6 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
); );
} }
function StatusRow({ label, value, tone = '' }) {
return (
<div className="flex items-center justify-between gap-1 text-xs">
<span className="text-slate-400">{label}</span>
<span className={`min-w-0 truncate font-medium ${tone || 'text-slate-100'}`}>{value}</span>
</div>
);
}
function PtzStatePanel({ ptz, compact = false }) {
const now = useSharedClock(1000, Boolean(ptz?.deadline));
const spotlightOn = isSpotlightOn(ptz?.light);
const irMode = normalizeIrMode(ptz?.ir?.state);
const publisher = ptz?.publisher || {};
const publisherStatus = publisher.running
? 'running'
: publisher.restartAt
? 'restarting'
: publisher.lastEvent || 'stopped';
const mode = ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator';
return (
<CardFrame title="Camera state" bodyClassName="space-y-0.5 p-1 text-sm">
<StatusRow label="Mode" value={mode} tone={ptz?.isOperator ? 'text-emerald-300' : ''} />
<StatusRow label="Operator" value={ptz?.operatorLabel || 'none'} />
<StatusRow label="Remaining" value={formatRemaining(ptz?.deadline, now)} />
<StatusRow label="Spotlight" value={spotlightOn ? 'On' : 'Off'} tone={spotlightOn ? 'text-emerald-300' : 'text-slate-200'} />
<StatusRow label="Infrared mode" value={irMode} />
<StatusRow label="Stream" value={ptz?.status || ptz?.error || 'idle'} tone={ptz?.error ? 'text-amber-300' : ''} />
{!compact ? <StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} /> : null}
{ptz?.blocked?.message ? (
<div className="rounded border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
{ptz.blocked.message}
</div>
) : null}
</CardFrame>
);
}
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) { function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz); const lookupUser = usePtzQueueLookup(ptz);
@@ -223,41 +188,30 @@ function PtzLightingControls({ ptz, disabled = false }) {
} }
function PtzMobileZoomButtons({ disabled = false }) { function PtzMobileZoomButtons({ disabled = false }) {
const { nudgeServo, stopAllMotion } = useControlActions(); const { setCameraAxisIntent } = useControlActions();
const repeatTimerRef = useRef(null);
const stopZoom = useCallback(() => { const stopZoom = useCallback(() => {
/* /*
Mobile zoom is intentionally routed through the normal camera-up/down Zero only releases the zoom axis. The PTZ adapter combines it with any
control action instead of emitting PTZ socket commands directly. That pan/tilt direction still held on the movement pad, so lifting one finger
keeps the zoom buttons on the same path as keyboard/gamepad camera tilt, cannot erase the other finger's intent.
and the PTZ adapter remains the one place that translates "camera nudge"
into Reolink zoom pulses.
*/ */
if (repeatTimerRef.current) { setCameraAxisIntent(0);
clearInterval(repeatTimerRef.current); }, [setCameraAxisIntent]);
repeatTimerRef.current = null;
}
stopAllMotion();
}, [stopAllMotion]);
const startZoom = useCallback( const startZoom = useCallback(
(direction) => (event) => { (direction) => (event) => {
/* /*
Send an immediate nudge and then repeat while held. The adapter turns Publish held state once. The adapter owns the single motion heartbeat,
each nudge into a short zoom pulse, so repeating the standard action is so this button no longer creates a second interval whose queued callback
the simplest way to get continuous hold-to-zoom without adding another could run after pointerup and restart zoom.
PTZ-specific command loop.
*/ */
event.preventDefault(); event.preventDefault();
if (disabled) return; if (disabled) return;
stopZoom(); event.currentTarget.setPointerCapture?.(event.pointerId);
nudgeServo(direction); setCameraAxisIntent(direction);
repeatTimerRef.current = setInterval(() => {
nudgeServo(direction);
}, 120);
}, },
[disabled, nudgeServo, stopZoom], [disabled, setCameraAxisIntent],
); );
const stopFromPointer = useCallback( const stopFromPointer = useCallback(
(event) => { (event) => {
@@ -272,16 +226,12 @@ function PtzMobileZoomButtons({ disabled = false }) {
() => () => { () => () => {
/* /*
A touch surface can unmount during orientation changes or fullscreen A touch surface can unmount during orientation changes or fullscreen
close while a pointer is still down. Clear the repeat timer here so a close while a pointer is still down. Explicitly clear zoom here because
held zoom button cannot keep firing camera-up/down actions after the an unmounted DOM node cannot deliver its pointerup/pointercancel event.
mobile controls have disappeared.
*/ */
if (repeatTimerRef.current) { setCameraAxisIntent(0);
clearInterval(repeatTimerRef.current);
repeatTimerRef.current = null;
}
}, },
[], [setCameraAxisIntent],
); );
return ( return (
@@ -293,7 +243,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
onPointerDown={startZoom(-1)} onPointerDown={startZoom(-1)}
onPointerUp={stopFromPointer} onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer} onPointerCancel={stopFromPointer}
onPointerLeave={stopFromPointer} onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
Zoom out Zoom out
@@ -305,7 +255,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
onPointerDown={startZoom(1)} onPointerDown={startZoom(1)}
onPointerUp={stopFromPointer} onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer} onPointerCancel={stopFromPointer}
onPointerLeave={stopFromPointer} onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
Zoom in Zoom in
@@ -540,8 +490,9 @@ function buildPtzTurnModel(ptz, selfId) {
function PtzMediaPane({ ptz, open, framed = true }) { function PtzMediaPane({ ptz, open, framed = true }) {
const isOperator = Boolean(ptz?.isOperator); const isOperator = Boolean(ptz?.isOperator);
const nonTurnVideoPolicy = useSessionSelector( const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots', const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
); );
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
/* /*
@@ -550,7 +501,15 @@ function PtzMediaPane({ ptz, open, framed = true }) {
canRequestLiveVideo(); this branch only chooses the expected browser render canRequestLiveVideo(); this branch only chooses the expected browser render
path and never unlocks movement controls. path and never unlocks movement controls.
*/ */
const shouldUseLiveVideo = isOperator || nonTurnVideoPolicy === 'live'; /*
A direct /ptz load renders before its automatic queue claim is acknowledged.
Do not mount the live player during that short pre-claim window: its first
token request would correctly be rejected, and PtzLiveVideo intentionally
treats authorization rejection as a terminal snapshot fallback. Once the
session confirms queue/operator membership, mounting the player creates a
fresh authorized request without changing shared retry or server policy.
*/
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo }); const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null; const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]); const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
@@ -585,7 +544,10 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}> <main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
<PtzMediaPane ptz={ptz} open framed /> <PtzMediaPane ptz={ptz} open framed />
</main> </main>
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto bg-neutral-950 text-sm"> {/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
while the shared PTZ page theme can show through the same compact gaps as the driver
layout instead of being covered by one solid sidebar rectangle. */}
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
<PtzQueueSummary ptz={ptz} /> <PtzQueueSummary ptz={ptz} />
{ptz?.isOperator ? ( {ptz?.isOperator ? (
<PtzLightingControls ptz={ptz} /> <PtzLightingControls ptz={ptz} />
@@ -595,8 +557,13 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
</CardFrame> </CardFrame>
)} )}
<PtzControlReference /> <PtzControlReference />
<PtzStatePanel ptz={ptz} /> <ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
<ReplaySourcesPanel panelId="ptz-controller-replay" /> {/*
Desktop keeps room controls as the final sidebar tool so camera
turn controls and replay remain above the less-frequent room-wide
actions. HomeAssistantControls owns its own feature and policy gate.
*/}
<HomeAssistantControls />
</aside> </aside>
</div> </div>
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden"> <div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
@@ -612,17 +579,22 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
); );
} }
function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) { function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
const landscape = layout === 'mobile-landscape';
const topHeightClass = landscape ? 'h-full min-h-[calc(100dvh-0.25rem)]' : 'h-[48dvh]';
const topGridClass = landscape
? 'grid-cols-[minmax(0,1fr)_13rem]'
: 'grid-cols-[minmax(0,1fr)_11rem]';
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5"> <div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
<section className={`mobile-touch-control grid ${topHeightClass} min-h-48 shrink-0 ${topGridClass} gap-0.5`}> {/*
<main className="relative min-h-0 overflow-hidden bg-black"> Landscape intentionally retains one control column beside the video.
This is the established PTZ interaction and avoids forcing rover-style
left/right columns onto a camera that has a smaller control inventory.
*/}
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
{/*
The video keeps one viewport of height, but the grid row is allowed to
grow when the control column is taller. That makes the sidebar's tail
extend below the video instead of forcing it into a nested scroller.
*/}
<div className="min-w-0 space-y-0.5">
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
<button <button
type="button" type="button"
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50" className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
@@ -633,57 +605,232 @@ function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) {
</button> </button>
<PtzMediaPane ptz={ptz} open framed={false} /> <PtzMediaPane ptz={ptz} open framed={false} />
</main> </main>
<aside className="min-h-0 overflow-y-auto"> {/*
The right control column is naturally taller than the viewport.
Placing room controls after the fixed-height video uses that left-
column space while the whole landscape page continues scrolling as
one surface.
*/}
<HomeAssistantControls />
</div>
{/*
Do not put overflow scrolling on this column. The surrounding PTZ
landscape content is the single page scroller, so a swipe over either
the video area or these controls advances the same document flow.
*/}
<aside className="min-h-0 space-y-0.5">
{/*
Landscape keeps all turn-critical controls in its one existing
sidebar. Queue position belongs first so the operator can confirm
control ownership before touching the camera, while replay follows
the lighting buttons because it is the next secondary action in
the same scroll column.
*/}
<PtzQueueSummary ptz={ptz} />
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} /> <PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-landscape"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</aside> </aside>
</section> </section>
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]"> <section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" /> <ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
<div className="space-y-0.5">
<PtzQueueSummary ptz={ptz} />
<PtzPresetPanel ptz={ptz} /> <PtzPresetPanel ptz={ptz} />
<PtzStatePanel ptz={ptz} compact />
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
</div>
</section> </section>
</div> </div>
); );
} }
export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) { function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
<button
type="button"
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
disabled={releasePending}
onClick={onClose}
>
Close
</button>
<PtzMediaPane ptz={ptz} open framed={false} />
</main>
{/*
Portrait gives the video its full available width and places controls
below it. Reusing the landscape sidebar width here was the source of the
cramped portrait presentation, while the controls themselves remain the
same shared PTZ controls used in landscape.
*/}
<section className="mobile-touch-control">
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
</section>
<section className="space-y-0.5">
{/*
Replay and presets are compact secondary actions, so portrait places
them in one equal-width row before the full-width queue and chat. The
explicit two-column grid keeps this arrangement local to portrait and
leaves the desktop and one-column landscape compositions unchanged.
*/}
<div className="grid grid-cols-2 items-start gap-0.5">
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-portrait"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
<PtzPresetPanel ptz={ptz} />
</div>
<PtzQueueSummary ptz={ptz} />
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
{/* Portrait keeps room controls immediately after chat as requested. */}
<HomeAssistantControls />
</section>
</div>
);
}
export function PtzControllerPage({ layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const { ptzRelease } = useSessionActions(); const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions(); const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false); const [releasePending, setReleasePending] = useState(false);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape'; const autoClaimSocketRef = useRef(null);
const routeExitReleaseTimerRef = useRef(null);
const participantRef = useRef(false);
const closingThroughButtonRef = useRef(false);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
// Route-exit cleanup runs after the last render, so retain the latest
// server-confirmed membership without making the lifecycle effect resubscribe.
participantRef.current = isParticipant;
}, [isParticipant]);
useEffect(() => {
if (routeExitReleaseTimerRef.current) {
clearTimeout(routeExitReleaseTimerRef.current);
routeExitReleaseTimerRef.current = null;
}
return () => {
if (!participantRef.current || closingThroughButtonRef.current) return;
/*
Browser Back and route navigation unmount the PTZ page without invoking
its Close button. Defer release by one task so React Strict Mode's
development-only cleanup/remount cycle can cancel it in the next setup;
a real route exit has no replacement setup, so membership is released.
This is intentionally membership-gated. An admin release command can
revoke the current operator even when the admin is not that operator,
so an admin merely visiting/leaving a disabled or unjoined page must not
emit a release command.
*/
routeExitReleaseTimerRef.current = setTimeout(() => {
routeExitReleaseTimerRef.current = null;
ptzRelease().catch(() => {});
}, 0);
};
}, [ptzRelease]);
useEffect(() => {
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
if (ptz.isOperator || ptz.queuedPosition) {
/*
Navigation from the driver queue normally arrives with membership
already established. Mark this socket complete so later session syncs
cannot turn that normal route transition into another claim request.
*/
autoClaimSocketRef.current = socketId;
return undefined;
}
if (autoClaimSocketRef.current === socketId) return undefined;
autoClaimSocketRef.current = socketId;
let active = true;
/*
A direct /ptz load still receives the ordinary user role first, which can
briefly assign a rover. Claiming through the existing server action is
deliberate: ptzCameraService releases that rover ownership before it
activates or queues this socket, keeping one authoritative transition.
The socket-keyed ref suppresses repeats caused by session updates and
React's development effect replay. The server claim is also idempotent for
an existing operator/queue member, which covers an acknowledgement racing
with a fresh public-state sync.
*/
ptzClaim().catch((err) => {
if (!active) return;
pushAlert({
id: `ptz-auto-claim-${socketId}`,
title: 'PTZ camera',
message: err?.message || 'Unable to join the PTZ queue.',
color: '#f59e0b',
lifetimeMs: 6000,
});
});
return () => {
// Do not emit or update UI from a rejected request after this route has
// unmounted; the server still owns completion of any request in flight.
active = false;
};
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
const releaseAndClose = useCallback(async () => { const releaseAndClose = useCallback(async () => {
if (releasePending) return; if (releasePending) return;
setReleasePending(true); setReleasePending(true);
closingThroughButtonRef.current = true;
try { try {
/* /*
Stop first so a held key/pointer cannot leave ONVIF continuous movement Stop first so a held key/pointer cannot leave ONVIF continuous movement
running while the server removes this socket from the PTZ queue. running while the server removes this socket from the PTZ queue.
*/ */
stopAllMotion?.(); stopAllMotion?.();
if (ptz?.isOperator || ptz?.queuedPosition) {
await ptzRelease(); await ptzRelease();
onClose?.(); }
navigate('/');
} catch (err) {
// A rejected manual release leaves the route mounted, so route-exit
// cleanup must remain armed for a later Back/navigation attempt.
closingThroughButtonRef.current = false;
throw err;
} finally { } finally {
setReleasePending(false); setReleasePending(false);
} }
}, [onClose, ptzRelease, releasePending, stopAllMotion]); }, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
if (!open) return null; if (!featureEnabled) {
return (
<main className={`flex min-h-[100dvh] items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
<p>The PTZ camera is not available.</p>
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
</CardFrame>
</main>
);
}
const controller = ( return (
/* <main className={`h-[100dvh] w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
The PTZ controller needs to cover the driver page, but it must not become {/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
the top-most application layer. Global fullscreen overlays like help, transparent body is what lets every desktop and mobile PTZ composition share one
quickstart, mode gates, and connection warnings are still part of the continuous pattern without threading theme props into each individual child panel. */}
active app state while PTZ is open, so this portal intentionally sits
below their z-30+ overlay stack instead of hiding them.
*/
<div className="fixed inset-0 z-20 h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
<CardFrame <CardFrame
title={isMobile ? '' : ptz?.name || 'PTZ Camera'} title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
actions={isMobile ? null : ( actions={isMobile ? null : (
@@ -695,23 +842,20 @@ export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
fillHeight fillHeight
clipOverflow={false} clipOverflow={false}
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black" className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
bodyClassName="relative min-h-0 flex-1" bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
> >
{isMobile ? ( {isMobile ? (
<PtzMobileFullscreen layout === 'mobile-landscape' ? (
ptz={ptz} <PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
layout={layout} ) : (
onClose={releaseAndClose} <PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
releasePending={releasePending} )
/>
) : ( ) : (
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} /> <PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
)} )}
</CardFrame> </CardFrame>
</div> </main>
); );
return createPortal(controller, document.body);
} }
export default function PtzQueueCard({ layout = 'desktop' }) { export default function PtzQueueCard({ layout = 'desktop' }) {
@@ -721,10 +865,10 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, ptzRelease } = useSessionActions(); const { ptzClaim, ptzRelease } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz); const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz); const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const now = useSharedClock(1000, Boolean(ptz?.deadline)); const now = useSharedClock(1000, Boolean(ptz?.deadline));
const [controllerOpen, setControllerOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown'); const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition); const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
@@ -735,7 +879,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
const handleRequest = async () => { const handleRequest = async () => {
if (!canUse || pending) return; if (!canUse || pending) return;
if (isParticipant) { if (isParticipant) {
setControllerOpen(true); navigate('/ptz');
return; return;
} }
setPending(true); setPending(true);
@@ -748,7 +892,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
dock-guard rejection does not strand the user in fullscreen. dock-guard rejection does not strand the user in fullscreen.
*/ */
if (response?.state?.isOperator || response?.state?.queuedPosition) { if (response?.state?.isOperator || response?.state?.queuedPosition) {
setControllerOpen(true); navigate('/ptz');
} }
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' }); trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
} catch (err) { } catch (err) {
@@ -784,7 +928,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
: 'request'; : 'request';
return ( return (
<>
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm"> <CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
<ul className="space-y-0.5 text-sm"> <ul className="space-y-0.5 text-sm">
<QueueTargetRow <QueueTargetRow
@@ -824,7 +967,5 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
</div> </div>
) : null} ) : null}
</CardFrame> </CardFrame>
<PtzFullscreenController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
</>
); );
} }
@@ -35,13 +35,16 @@ function selectedKeysEqual(left, right) {
return true; return true;
} }
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) { export default function ReplaySourcesPanel({
panelId = 'replay-sources',
fillHeight = false,
defaultSelectedKey = null,
}) {
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []); const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null); const replayState = useSessionSelector((state) => state.session?.replay || null);
const latestReplay = useSessionSelector((state) => state.latestReplay);
const { triggerReplay } = useSessionActions(); const { triggerReplay } = useSessionActions();
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]); const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {}); const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
@@ -65,20 +68,35 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const activeReplayJob = useSessionSelector((state) => ( const activeReplayJob = useSessionSelector((state) => (
activeJobId ? state.replayJobs?.[activeJobId] || null : null activeJobId ? state.replayJobs?.[activeJobId] || null : null
)); ));
const latestReplayJobId = latestReplay?.jobId || null; // The job id is deliberately local to this mounted panel. Reading the global
// latestReplay value here caused a newly mounted panel to resurrect the last
// replay popup even though this panel did not request it. The job record can
// remain in shared session state for asynchronous socket updates; selecting
// it through this panel-owned id keeps popup ownership and lifetime local.
const panelReplay = activeReplayJob?.media || null;
const panelReplayJobId = panelReplay?.jobId || null;
const showPanelReplay = Boolean( const showPanelReplay = Boolean(
latestReplay?.url && panelReplay?.url &&
latestReplayJobId && panelReplayJobId &&
dismissedPanelReplayId !== latestReplayJobId, dismissedPanelReplayId !== panelReplayJobId,
); );
const defaults = useMemo(() => { const availableDefaultKey = useMemo(() => {
const roverId = assignmentRoverId; // PTZ layouts provide their camera key explicitly so entering the dedicated
if (roverId) { // camera page does not inherit the user's assigned rover. Waiting until the
return [`rover:${roverId}`]; // source is actually advertised also handles the initial session load: an
// unavailable key is never left selected, but it becomes the default as
// soon as the server publishes that replay source.
if (defaultSelectedKey && sources.some((source) => source.key === defaultSelectedKey)) {
return defaultSelectedKey;
} }
return []; const roverKey = assignmentRoverId ? `rover:${assignmentRoverId}` : null;
}, [assignmentRoverId]); if (roverKey && sources.some((source) => source.key === roverKey)) {
return roverKey;
}
return null;
}, [assignmentRoverId, defaultSelectedKey, sources]);
const defaults = useMemo(() => (availableDefaultKey ? [availableDefaultKey] : []), [availableDefaultKey]);
const defaultTitle = useMemo(() => { const defaultTitle = useMemo(() => {
const roverId = assignmentRoverId || null; const roverId = assignmentRoverId || null;
@@ -225,9 +243,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
{showPanelReplay ? ( {showPanelReplay ? (
<div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2"> <div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2">
<ReplayReadyPopup <ReplayReadyPopup
replay={latestReplay} replay={panelReplay}
variant="floating-panel" variant="floating-panel"
onClose={() => setDismissedPanelReplayId(latestReplayJobId)} onClose={() => setDismissedPanelReplayId(panelReplayJobId)}
/> />
</div> </div>
) : null} ) : null}
@@ -261,6 +279,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
setTitleDirty(true); setTitleDirty(true);
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next })); saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
}} }}
onKeyDown={(event) => {
// Enter is the keyboard equivalent of clicking Replay. Ignore
// composition events so confirming an IME candidate cannot
// accidentally submit a replay before the title is complete.
// handleReplay remains the single authority for cooldown,
// lockdown, busy, and empty-source checks.
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
event.preventDefault();
handleReplay();
}}
placeholder={defaultTitle} placeholder={defaultTitle}
maxLength={120} maxLength={120}
/> />
+15 -4
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 RoomCameraPanel from '../RoomCameraPanel/index.jsx'; import RoomCameraPanel from '../RoomCameraPanel/index.jsx';
import KinectPanel from '../KinectPanel/index.jsx'; import KinectPanel from '../KinectPanel/index.jsx';
import BalanceBoardPanel from '../BalanceBoardPanel/index.jsx';
import HomeAssistantControls from '../HomeAssistantControls/index.jsx'; import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import SettingsPanel from '../SettingsPanel/index.jsx'; import SettingsPanel from '../SettingsPanel/index.jsx';
import HelpPanel from '../HelpPanel/index.jsx'; import HelpPanel from '../HelpPanel/index.jsx';
@@ -33,7 +34,7 @@ import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js'; import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
import { themeGapClass, themeStackClass } from '../../themeFlags.js'; import { themeGapClass, themeStackClass } from '../../themes/index.js';
import { trackAnalyticsEvent } from '../../analytics/index.js'; import { trackAnalyticsEvent } from '../../analytics/index.js';
const CHAT_DOCK_INITIAL_HEIGHT = 224; const CHAT_DOCK_INITIAL_HEIGHT = 224;
@@ -195,9 +196,18 @@ function QueueReplayLinksRow() {
removes that item instead of preserving an empty grid column. removes that item instead of preserving an empty grid column.
*/ */
return ( return (
<div className={`flex ${themeGapClass}`}> <div className={`flex items-stretch ${themeGapClass}`}>
<div className={`min-w-0 basis-0 grow-[1] space-y-0.5`}> <div className="relative min-w-0 basis-0 grow-[1]">
<RoverQueuesPanel /> {/*
The absolutely positioned queue card is removed from flex cross-size
calculation. Replay and the links/PTZ stack therefore define the row
height entirely through normal CSS layout; this relative column then
stretches to that established height and gives the queue card an exact
containing block to fill without any JavaScript measurement.
*/}
<div className="absolute inset-0 min-h-0">
<RoverQueuesPanel fillHeight />
</div>
</div> </div>
<div className="min-w-0 basis-0 grow-[0.9]"> <div className="min-w-0 basis-0 grow-[0.9]">
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight /> <ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
@@ -420,6 +430,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<div className={`flex flex-col ${themeGapClass}`}> <div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard /> <NeatoCard />
<LiftCard /> <LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel /> <BarcodeGamesPanel />
<OdometerPanel /> <OdometerPanel />
<ButtonBoxPanel /> <ButtonBoxPanel />
@@ -1,14 +1,14 @@
// Rover Queues Panel // Rover Queues Panel
// Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file. // Purpose: Defines the Rover Queues Panel 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 { useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js'; import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx'; import QueueTargetRow from '../QueueTargetRow/index.jsx';
import { trackAnalyticsEvent } from '../../analytics/index.js'; import { trackAnalyticsEvent } from '../../analytics/index.js';
import { openExternalRover } from '../../lib/interInstanceTransfer.js'; import { openExternalRover } from '../../lib/interInstanceTransfer.js';
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx'; import { ExternalInstancesCompact, InterInstancePopup } from '../InterInstancePanel/index.jsx';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
@@ -25,6 +25,74 @@ function batteryClass(rover) {
return 'text-emerald-300'; return 'text-emerald-300';
} }
function ScrollableQueueContent({ enabled = false, children }) {
const viewportRef = useRef(null);
const contentRef = useRef(null);
const [canScrollDown, setCanScrollDown] = useState(false);
const measureScrollRemainder = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) return;
/*
Fractional layout measurements can leave a sub-pixel remainder even at
the bottom. The tolerance keeps the cue from flickering there while still
showing it for any meaningful hidden queue content.
*/
const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
setCanScrollDown(remaining > 2);
}, []);
useEffect(() => {
if (!enabled) return undefined;
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return undefined;
/*
Queue membership, user chips, and remote instances all update from live
session state and may change the content height without resizing the
window. Observing both boxes keeps the overflow cue accurate without
using JavaScript to calculate or assign the panel's actual height.
*/
const observer = new ResizeObserver(measureScrollRemainder);
observer.observe(viewport);
observer.observe(content);
const animationFrame = window.requestAnimationFrame(measureScrollRemainder);
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [enabled, measureScrollRemainder]);
if (!enabled) return children;
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={viewportRef}
className="min-h-0 flex-1 overflow-y-auto"
onScroll={measureScrollRemainder}
>
<div ref={contentRef}>{children}</div>
</div>
{canScrollDown ? (
/*
This indicator is deliberately removed from layout so it consumes no
permanent panel height. It also adds no padding to the scroll content,
keeping scrollHeight stable when the indicator disappears at the
bottom. The explicit stacking level and opaque background keep queue
cards from painting through or over the message.
*/
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-neutral-950 px-1 py-0.5 text-center text-xs font-semibold text-slate-200">
Scroll for more
</div>
) : null}
</div>
);
}
export default function RoverQueuesPanel({ export default function RoverQueuesPanel({
title = 'Rovers', title = 'Rovers',
roster: rosterOverride = null, roster: rosterOverride = null,
@@ -32,6 +100,7 @@ export default function RoverQueuesPanel({
users: usersOverride = null, users: usersOverride = null,
externalInstance = null, externalInstance = null,
disabledOverlay = '', disabledOverlay = '',
fillHeight = false,
}) { }) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const localRoster = useSessionSelector((state) => state.session?.roster ?? []); const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
@@ -50,6 +119,7 @@ export default function RoverQueuesPanel({
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 [interInstancePopupOpen, setInterInstancePopupOpen] = useState(false);
const externalMode = Boolean(externalInstance); const externalMode = Boolean(externalInstance);
const externalBlocked = Boolean(externalMode && disabledOverlay); const externalBlocked = Boolean(externalMode && disabledOverlay);
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false; const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
@@ -146,8 +216,8 @@ export default function RoverQueuesPanel({
} }
} }
const headerActions = const rebootAction =
!externalMode && role !== 'spectator' && assignedRoverId ? ( role !== 'spectator' && assignedRoverId ? (
<button <button
type="button" type="button"
onClick={handleRebootOwnRover} onClick={handleRebootOwnRover}
@@ -159,8 +229,17 @@ export default function RoverQueuesPanel({
</button> </button>
) : null; ) : null;
const headerActions = !externalMode ? rebootAction : null;
return ( return (
<CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm"> <>
<CardFrame
title={title}
actions={headerActions}
fillHeight={fillHeight}
bodyClassName="space-y-0.5 text-sm"
>
<ScrollableQueueContent enabled={fillHeight}>
<div className="relative space-y-0.5"> <div className="relative space-y-0.5">
{rosterItems.length === 0 ? ( {rosterItems.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p> <p className="text-sm text-slate-500">No rovers registered.</p>
@@ -228,8 +307,20 @@ export default function RoverQueuesPanel({
{disabledOverlay} {disabledOverlay}
</div> </div>
) : null} ) : null}
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null} {!externalMode && interInstanceEnabled ? (
<ExternalInstancesCompact onBrowse={() => setInterInstancePopupOpen(true)} />
) : null}
</div> </div>
</ScrollableQueueContent>
</CardFrame> </CardFrame>
{interInstancePopupOpen ? (
/*
The popup remains owned by the local Rover Queues panel because its
title-bar action opens it. External queue panels never render that
action, which prevents recursively opening browsers from remote rows.
*/
<InterInstancePopup onClose={() => setInterInstancePopupOpen(false)} />
) : null}
</>
); );
} }
+132 -1
View File
@@ -1,7 +1,7 @@
// Settings Panel // Settings Panel
// Purpose: Defines the Settings Panel module and the local helpers/components used in this file. // Purpose: Defines the Settings Panel 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 { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useControlActions, useControlSelector } from '../../controls/index.js'; import { useControlActions, useControlSelector } from '../../controls/index.js';
import AuthPanel from '../AuthPanel/index.jsx'; import AuthPanel from '../AuthPanel/index.jsx';
import AdminPanel from '../AdminPanel/index.jsx'; import AdminPanel from '../AdminPanel/index.jsx';
@@ -19,6 +19,13 @@ import { useSocket } from '../../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js'; import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
import {
DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS,
getPageTheme,
getPageThemeClass,
normalizePageThemeKey,
} from '../../themes/index.js';
const manualTabs = [ const manualTabs = [
{ key: 'start', label: 'Start OI' }, { key: 'start', label: 'Start OI' },
@@ -66,6 +73,24 @@ function SettingHelp({ children }) {
return <p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">{children}</p>; return <p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">{children}</p>;
} }
function ThemePreviewCard({ title, className = '' }) {
// Use the production CardFrame rather than a lookalike rectangle. This makes the demonstration
// honest about borders, opaque card surfaces, and the exact amount of theme visible in a gap.
return (
<CardFrame
title={title}
className={className}
bodyClassName="flex min-h-0 flex-1 flex-col justify-center gap-1 p-1"
fillHeight
>
{/* Neutral placeholder lines suggest real panel content without making the preview look like
an interactive control surface or tying it to any one driver/PTZ layout. */}
<div className="h-1.5 w-4/5 rounded-full bg-neutral-600/80" />
<div className="h-1.5 w-3/5 rounded-full bg-neutral-700/90" />
</CardFrame>
);
}
function RangeSetting({ label, value, disabled = false, onChange }) { function RangeSetting({ label, value, disabled = false, onChange }) {
// Range settings need enough horizontal room for accurate pointer input, so the slider spans // Range settings need enough horizontal room for accurate pointer input, so the slider spans
// the row while the percentage value stays beside the label for quick feedback. // the row while the percentage value stays beside the label for quick feedback.
@@ -116,6 +141,7 @@ export default function SettingsPanel() {
swapMobileControlColumns: false, swapMobileControlColumns: false,
driveMacroBackoffEnabled: true, driveMacroBackoffEnabled: true,
interInstanceTransferSettings: true, interInstanceTransferSettings: true,
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
}); });
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS); const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
@@ -126,6 +152,11 @@ export default function SettingsPanel() {
? pageSettings.driveMacroBackoffEnabled ? pageSettings.driveMacroBackoffEnabled
: true; : true;
const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false; const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false;
const savedPageThemeKey = normalizePageThemeKey(pageSettings?.backgroundTheme);
const [previewPageThemeKey, setPreviewPageThemeKey] = useState(savedPageThemeKey);
const previewPageTheme = getPageTheme(previewPageThemeKey);
const savedPageTheme = getPageTheme(savedPageThemeKey);
const hasUnsavedPageTheme = previewPageThemeKey !== savedPageThemeKey;
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume; const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume; const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume; const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
@@ -141,6 +172,13 @@ export default function SettingsPanel() {
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter); const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]); const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
useEffect(() => {
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
// transfer. Resynchronize only when the persisted key changes; browsing the local preview does
// not touch pageSettings, so Previous/Next choices are not accidentally reset.
setPreviewPageThemeKey(savedPageThemeKey);
}, [savedPageThemeKey]);
const sensorButtons = useMemo( const sensorButtons = useMemo(
() => [ () => [
{ key: 'start', label: 'Enable stream', enable: true }, { key: 'start', label: 'Enable stream', enable: true },
@@ -202,6 +240,32 @@ export default function SettingsPanel() {
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked }); trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
}; };
const movePageThemePreview = (direction) => {
const currentIndex = PAGE_THEME_OPTIONS.findIndex((theme) => theme.key === previewPageThemeKey);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
// Theme browsing wraps in both directions so the Back and Next buttons remain useful at the
// ends of the catalog instead of forcing the user to reverse through every previous option.
const nextIndex = (safeIndex + direction + PAGE_THEME_OPTIONS.length) % PAGE_THEME_OPTIONS.length;
setPreviewPageThemeKey(PAGE_THEME_OPTIONS[nextIndex].key);
};
const handlePageThemeSelect = (event) => {
setPreviewPageThemeKey(normalizePageThemeKey(event.target.value));
};
const handlePageThemeSave = () => {
// Browsing is intentionally local. Persist only this explicit choice so opening Page settings
// and experimenting with patterns cannot unexpectedly alter the driver or PTZ page background.
savePageSettings((current) => ({
...(current ?? {}),
backgroundTheme: previewPageThemeKey,
}));
trackAnalyticsEvent('settings_change', {
setting: 'backgroundTheme',
value: previewPageThemeKey,
});
};
const handleVideoFilterChange = (event) => { const handleVideoFilterChange = (event) => {
const nextFilter = normalizeVideoFilter(event.target.value); const nextFilter = normalizeVideoFilter(event.target.value);
@@ -238,6 +302,73 @@ export default function SettingsPanel() {
stretched column. Audio spans both columns because volume sliders need extra width stretched column. Audio spans both columns because volume sliders need extra width
for comfortable pointer control. */} for comfortable pointer control. */}
<div className="grid gap-1.5 lg:grid-cols-2"> <div className="grid gap-1.5 lg:grid-cols-2">
<CardFrame
title="Background theme"
className="lg:col-span-2"
bodyClassName="space-y-1.5 p-1 text-sm"
>
{/* Back/Next provide fast visual browsing, while the dropdown remains the direct
route to a known theme in a growing catalog. Neither control saves implicitly. */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
<button
type="button"
className="button-dark min-w-16 text-sm"
onClick={() => movePageThemePreview(-1)}
>
Back
</button>
<select
aria-label="Preview background theme"
value={previewPageThemeKey}
onChange={handlePageThemeSelect}
className="field-input min-w-0 px-1 py-0.5 text-sm"
>
{PAGE_THEME_OPTIONS.map((theme) => (
<option key={theme.key} value={theme.key}>
{theme.label}
</option>
))}
</select>
<button
type="button"
className="button-dark min-w-16 text-sm"
onClick={() => movePageThemePreview(1)}
>
Next
</button>
</div>
{/* This miniature layout contains both a full-height vertical seam and horizontal
seams between stacked cards. It exercises the exact gap directions the artwork
must serve on the driver and PTZ pages. */}
<div
className={`page-theme-preview grid h-32 grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)_minmax(0,0.85fr)] grid-rows-2 gap-0.5 overflow-hidden rounded border border-neutral-500/70 p-0.5 ${getPageThemeClass(previewPageThemeKey)}`}
>
<ThemePreviewCard title="Video" className="row-span-2" />
<ThemePreviewCard title="Controls" />
<ThemePreviewCard title="Queue" />
<ThemePreviewCard title="Chat" className="col-span-2" />
</div>
<div className="flex flex-wrap items-center justify-between gap-1.5">
<p className="min-w-0 text-xs text-white">
Previewing <span className="font-semibold">{previewPageTheme.label}</span>
{hasUnsavedPageTheme ? (
<span className="text-slate-300">; saved theme is {savedPageTheme.label}.</span>
) : (
<span className="text-slate-300">; this is your saved theme.</span>
)}
</p>
<button
type="button"
className="button-dark min-w-24 text-sm disabled:cursor-default disabled:opacity-45"
disabled={!hasUnsavedPageTheme}
onClick={handlePageThemeSave}
>
Save theme
</button>
</div>
</CardFrame>
<CardFrame title="HUD" bodyClassName="space-y-1 p-1 text-sm"> <CardFrame title="HUD" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]"> <SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
<input <input
+1 -1
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the Tabs module and the local helpers/components used in this file. // Purpose: Defines the Tabs 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 { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react'; import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
import { themeGapClass, themeStackClass } from '../../themeFlags.js'; import { themeGapClass, themeStackClass } from '../../themes/index.js';
const TabsContext = createContext(null); const TabsContext = createContext(null);
+32 -18
View File
@@ -1,7 +1,7 @@
// Vip PTZ Camera Card // Vip PTZ Camera Card
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera. // Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative. // Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ChatPanel from '../ChatPanel/index.jsx'; import ChatPanel from '../ChatPanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
@@ -11,13 +11,12 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx'; import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx'; import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js'; import { useControlActions, useControlSelector } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js'; import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { isFeatureEnabled } from '../../lib/features.js'; import { isFeatureEnabled } from '../../lib/features.js';
const PTZ_CAMERA_ID = 'ptz-camera'; const PTZ_CAMERA_ID = 'ptz-camera';
const PTZ_ZOOM_SPEED = 0.55;
function formatRemaining(deadline) { function formatRemaining(deadline) {
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000)); const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
@@ -218,22 +217,25 @@ function PtzLightingControls({ ptz, disabled = false }) {
} }
function PtzMobileZoomButtons({ disabled = false }) { function PtzMobileZoomButtons({ disabled = false }) {
const { ptzMove, ptzStop } = useSessionActions(); const { setCameraAxisIntent } = useControlActions();
const stopZoom = useCallback(() => { const stopZoom = useCallback(() => {
ptzStop().catch(() => {}); // This is a zoom-only release; the shared adapter retains any simultaneous
}, [ptzStop]); // pan/tilt intent from the movement pad in its next combined motion state.
setCameraAxisIntent(0);
}, [setCameraAxisIntent]);
const startZoom = useCallback( const startZoom = useCallback(
(direction) => (event) => { (direction) => (event) => {
/* /*
Mobile needs explicit zoom targets because the regular mobile drive pad The adapter owns renewal for held PTZ state. Publishing the direction
is already used for pan/tilt. Desktop does not render these buttons; it once avoids a component-local repeat timer and keeps this legacy card on
uses the mapped camera up/down controls shown in the reference panel. the exact same motion path as the dedicated PTZ route.
*/ */
event.preventDefault(); event.preventDefault();
if (disabled) return; if (disabled) return;
ptzMove({ pan: 0, tilt: 0, zoom: direction * PTZ_ZOOM_SPEED }).catch(() => {}); event.currentTarget.setPointerCapture?.(event.pointerId);
setCameraAxisIntent(direction);
}, },
[disabled, ptzMove], [disabled, setCameraAxisIntent],
); );
const stopFromPointer = useCallback( const stopFromPointer = useCallback(
(event) => { (event) => {
@@ -244,6 +246,15 @@ function PtzMobileZoomButtons({ disabled = false }) {
[disabled, stopZoom], [disabled, stopZoom],
); );
useEffect(
() => () => {
// Orientation changes can unmount the button before pointerup. Clear the
// held zoom state explicitly instead of waiting for the server watchdog.
setCameraAxisIntent(0);
},
[setCameraAxisIntent],
);
return ( return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm"> <div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<button <button
@@ -253,7 +264,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
onPointerDown={startZoom(-1)} onPointerDown={startZoom(-1)}
onPointerUp={stopFromPointer} onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer} onPointerCancel={stopFromPointer}
onPointerLeave={stopFromPointer} onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
Zoom out Zoom out
@@ -265,7 +276,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
onPointerDown={startZoom(1)} onPointerDown={startZoom(1)}
onPointerUp={stopFromPointer} onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer} onPointerCancel={stopFromPointer}
onPointerLeave={stopFromPointer} onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
Zoom in Zoom in
@@ -326,8 +337,8 @@ function PtzControlReference() {
function PtzController({ open, onClose, layout = 'desktop' }) { function PtzController({ open, onClose, layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isOperator = Boolean(ptz?.isOperator); const isOperator = Boolean(ptz?.isOperator);
const nonTurnVideoPolicy = useSessionSelector( const nonTurnSnapshotsActive = useSessionSelector(
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots', (state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
); );
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape'; const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions(); const { ptzRelease } = useSessionActions();
@@ -336,7 +347,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
users see live video only when the central non-turn video policy allows it; users see live video only when the central non-turn video policy allows it;
all movement and light controls still remain guarded by isOperator. all movement and light controls still remain guarded by isOperator.
*/ */
const shouldUseLiveVideo = isOperator || nonTurnVideoPolicy === 'live'; const shouldUseLiveVideo = isOperator || !nonTurnSnapshotsActive;
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo }); const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null; const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [releasePending, setReleasePending] = useState(false); const [releasePending, setReleasePending] = useState(false);
@@ -384,7 +395,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
<ChatPanel fillHeight title="Chat" /> <ChatPanel fillHeight title="Chat" />
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay" /> <ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<PtzStatePanel <PtzStatePanel
@@ -410,7 +421,10 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
<ChatPanel fillHeight title="Chat" /> <ChatPanel fillHeight title="Chat" />
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" /> <ReplaySourcesPanel
panelId="ptz-controller-replay-mobile"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<PtzStatePanel <PtzStatePanel
+53 -2
View File
@@ -30,6 +30,12 @@ const CHAT_FOCUS_DEFAULT = {
selfSocketId: null, selfSocketId: null,
}; };
const CHAT_HISTORY_DEFAULT = {
messageHistory: [],
};
const CHAT_HISTORY_LIMIT = 10;
const CHAT_HISTORY_ENTRY_LIMIT = 200;
// Chat messages and typing indicators are the highest-churn chat data. Keeping // Chat messages and typing indicators are the highest-churn chat data. Keeping
// them in their own context lets transcript components update without forcing // them in their own context lets transcript components update without forcing
// controlled composer inputs to re-render and re-commit unchanged attributes. // controlled composer inputs to re-render and re-commit unchanged attributes.
@@ -44,6 +50,12 @@ const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
// blur events, but it should not be tied to incoming chat traffic either. // blur events, but it should not be tied to incoming chat traffic either.
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT); const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
// Sent-message history has its own subscription because it changes only when
// this browser successfully posts a message. Keeping it separate prevents the
// transcript and focus consumers from re-rendering when the persisted history
// changes, while still giving every mounted composer one shared history source.
const ChatHistoryContext = createContext(CHAT_HISTORY_DEFAULT);
const ChatContext = createContext({ const ChatContext = createContext({
...CHAT_TIMELINE_DEFAULT, ...CHAT_TIMELINE_DEFAULT,
...CHAT_ACTIONS_DEFAULT, ...CHAT_ACTIONS_DEFAULT,
@@ -56,6 +68,7 @@ export function ChatProvider({ children }) {
const { pushAlert } = useSessionActions(); const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' }); const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
const { value: chatSettings, save: saveChatSettings } = useSettingsNamespace('chat', CHAT_HISTORY_DEFAULT);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]); const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false); const [isChatFocused, setIsChatFocused] = useState(false);
@@ -229,11 +242,29 @@ export function ChatProvider({ children }) {
hasTts: Boolean(tts), hasTts: Boolean(tts),
length: typeof text === 'string' ? text.trim().length : 0, length: typeof text === 'string' ? text.trim().length : 0,
}); });
/*
Record only messages accepted by the server so ArrowUp never
recalls a draft that failed to send. The settings subsystem uses
one browser cookie for every namespace, so both the entry length
and history count are deliberately bounded to leave room for the
user's other persisted preferences.
*/
const historyEntry = typeof text === 'string' ? text.slice(0, CHAT_HISTORY_ENTRY_LIMIT) : '';
if (historyEntry) {
saveChatSettings((current) => {
const currentHistory = Array.isArray(current?.messageHistory) ? current.messageHistory : [];
return {
...(current || {}),
messageHistory: [...currentHistory.slice(-(CHAT_HISTORY_LIMIT - 1)), historyEntry],
};
});
}
resolve(resp); resolve(resp);
} }
}); });
}), }),
[profileImage, socket], [profileImage, saveChatSettings, socket],
); );
const registerInputRef = useCallback((el, options = {}) => { const registerInputRef = useCallback((el, options = {}) => {
@@ -301,13 +332,23 @@ export function ChatProvider({ children }) {
[isChatFocused, session?.socketId], [isChatFocused, session?.socketId],
); );
const historyValue = useMemo(
() => ({
// Treat malformed or hand-edited cookie data as an empty history. This
// keeps keyboard navigation safe without mutating unrelated settings.
messageHistory: Array.isArray(chatSettings?.messageHistory) ? chatSettings.messageHistory : [],
}),
[chatSettings],
);
const value = useMemo( const value = useMemo(
() => ({ () => ({
...timelineValue, ...timelineValue,
...actionsValue, ...actionsValue,
...focusValue, ...focusValue,
...historyValue,
}), }),
[actionsValue, focusValue, timelineValue], [actionsValue, focusValue, historyValue, timelineValue],
); );
return ( return (
@@ -316,7 +357,9 @@ export function ChatProvider({ children }) {
<ChatTimelineContext.Provider value={timelineValue}> <ChatTimelineContext.Provider value={timelineValue}>
<ChatActionsContext.Provider value={actionsValue}> <ChatActionsContext.Provider value={actionsValue}>
<ChatFocusContext.Provider value={focusValue}> <ChatFocusContext.Provider value={focusValue}>
<ChatHistoryContext.Provider value={historyValue}>
<ChatContext.Provider value={value}>{children}</ChatContext.Provider> <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
</ChatHistoryContext.Provider>
</ChatFocusContext.Provider> </ChatFocusContext.Provider>
</ChatActionsContext.Provider> </ChatActionsContext.Provider>
</ChatTimelineContext.Provider> </ChatTimelineContext.Provider>
@@ -354,3 +397,11 @@ export function useChatFocus() {
} }
return ctx; return ctx;
} }
export function useChatHistory() {
const ctx = useContext(ChatHistoryContext);
if (!ctx) {
throw new Error('useChatHistory must be used inside ChatProvider');
}
return ctx;
}
-2
View File
@@ -426,8 +426,6 @@ export function SessionProvider({ children }) {
emitWithAck('session:privateSafety:set', { roverId, safety }), emitWithAck('session:privateSafety:set', { roverId, safety }),
ptzClaim: () => emitWithAck('ptzCamera:claim'), ptzClaim: () => emitWithAck('ptzCamera:claim'),
ptzRelease: () => emitWithAck('ptzCamera:release'), ptzRelease: () => emitWithAck('ptzCamera:release'),
ptzMove: (payload = {}) => emitWithAck('ptzCamera:move', payload),
ptzStop: () => emitWithAck('ptzCamera:stop'),
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload), ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload), ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'), ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
+5 -1
View File
@@ -231,7 +231,7 @@ export function TelemetryProvider({ children }) {
); );
useEffect(() => { useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) { function handleSensorFrame({ roverId, sensors = {}, frame = {}, overcurrentProtection = null }) {
if (!roverId) return; if (!roverId) return;
const previous = framesRef.current[roverId] ?? {}; const previous = framesRef.current[roverId] ?? {};
framesRef.current = { framesRef.current = {
@@ -240,6 +240,10 @@ export function TelemetryProvider({ children }) {
...previous, ...previous,
roverId, roverId,
sensors, sensors,
// Protection is server-calculated policy state, not a native Roomba
// sensor. Keeping it beside `sensors` preserves that distinction while
// allowing selectors to read one coherent telemetry snapshot.
overcurrentProtection,
raw: frame?.data || null, raw: frame?.data || null,
receivedAt: Date.now(), receivedAt: Date.now(),
}, },
+50 -63
View File
@@ -30,11 +30,7 @@ import { canonicalizeKeyInput } from './keymapUtils.js';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js'; import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import { import { useOvercurrentLimiter } from './overcurrentLimiter.js';
applyAuxOvercurrentScale,
applyDriveOvercurrentScale,
useOvercurrentLimiter,
} from './overcurrentLimiter.js';
import { usePtzControlAdapter } from './ptzControlAdapter.js'; import { usePtzControlAdapter } from './ptzControlAdapter.js';
const ControlSystemContext = createContext(null); const ControlSystemContext = createContext(null);
@@ -49,6 +45,7 @@ const CONTROL_ACTION_NAMES = [
'setAuxMotors', 'setAuxMotors',
'setServoAngle', 'setServoAngle',
'nudgeServo', 'nudgeServo',
'setCameraAxisIntent',
'goServoHome', 'goServoHome',
'setCameraPrecisionMode', 'setCameraPrecisionMode',
'runMacro', 'runMacro',
@@ -176,15 +173,13 @@ export function ControlSystemProvider({ children }) {
); );
const { homeAssistantSetState } = useSessionActions(); const { homeAssistantSetState } = useSessionActions();
const overcurrentLimiter = useOvercurrentLimiter(roverId); const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback( /*
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune), Motor commands now remain raw until they reach the server-owned protection
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales], service. Applying another transform here would make non-admin commands pass
); through two independent limiters and would let browser lifecycle determine
const auxTransform = useCallback( whether protection exists at all.
(values) => applyAuxOvercurrentScale(values, overcurrentLimiter.scales, overcurrentLimiter.adminImmune), */
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales], const pipeline = useCommandPipeline();
);
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const ptzControls = usePtzControlAdapter(); const ptzControls = usePtzControlAdapter();
const turnOnAllLights = useCallback(() => { const turnOnAllLights = useCallback(() => {
@@ -288,39 +283,6 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/record-intent' }); dispatch({ type: 'control/record-intent' });
}, []); }, []);
const driveSpeedsRef = useRef(state.drive.speeds);
const auxValuesRef = useRef(state.aux);
const limiterScaleToken = useMemo(() => JSON.stringify(overcurrentLimiter.scales), [overcurrentLimiter.scales]);
const limiterDriveSentAtRef = useRef(0);
const limiterAuxSentAtRef = useRef(0);
useEffect(() => {
driveSpeedsRef.current = state.drive.speeds;
}, [state.drive.speeds]);
useEffect(() => {
auxValuesRef.current = state.aux;
}, [state.aux]);
useEffect(() => {
if (!pipeline.roverId || overcurrentLimiter.adminImmune || !overcurrentLimiter.isActive) return;
const outputRateMs = Math.max(0, Number(overcurrentLimiter?.config?.outputRateMs) || 0);
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const drive = driveSpeedsRef.current || { left: 0, right: 0 };
const aux = auxValuesRef.current || { main: 0, side: 0, vacuum: 0 };
const driveActive = Boolean(drive.left || drive.right);
const auxActive = Boolean(aux.main || aux.side || aux.vacuum);
if (!driveActive && !auxActive) return;
if (driveActive && now - limiterDriveSentAtRef.current >= outputRateMs) {
limiterDriveSentAtRef.current = now;
pipeline.sendDriveDirect(drive);
}
if (auxActive && now - limiterAuxSentAtRef.current >= outputRateMs) {
limiterAuxSentAtRef.current = now;
pipeline.sendAuxMotors(aux);
}
}, [limiterScaleToken, overcurrentLimiter.adminImmune, overcurrentLimiter.config, overcurrentLimiter.isActive, pipeline]);
const setDriveVector = useCallback( const setDriveVector = useCallback(
(vector, meta = {}) => { (vector, meta = {}) => {
const speedOptions = { ...(meta.speedOptions || {}) }; const speedOptions = { ...(meta.speedOptions || {}) };
@@ -391,21 +353,12 @@ export function ControlSystemProvider({ children }) {
const setServoAngle = useCallback( const setServoAngle = useCallback(
(value, options = {}) => { (value, options = {}) => {
if (ptzControls.isActive) {
/* /*
Servo-capable rover controls converge here from keyboard, gamepad, Absolute servo positions belong only to rover hardware. PTZ zoom now
desktop, and mobile. When the active control target is the PTZ camera, enters through setCameraAxisIntent as a signed held velocity, so this
route the intent through the PTZ adapter instead of making the rover function must not infer zoom direction by comparing unrelated absolute
command pipeline understand camera zoom semantics. angle values from gamepad/manual-dock callers.
*/ */
const baseline = typeof servoAngleRef.current === 'number' ? servoAngleRef.current : 0;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return;
ptzControls.pulseZoom(numeric - baseline);
servoAngleRef.current = numeric;
recordControlIntent();
return;
}
if (!pipeline.servoConfig) return; if (!pipeline.servoConfig) return;
const force = Boolean(options?.force); const force = Boolean(options?.force);
if (state.manualDockAssist?.active && !force) return; if (state.manualDockAssist?.active && !force) return;
@@ -415,13 +368,24 @@ export function ControlSystemProvider({ children }) {
servoAngleRef.current = clamped; servoAngleRef.current = clamped;
recordControlIntent(); recordControlIntent();
}, },
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active], [pipeline, recordControlIntent, state.manualDockAssist?.active],
); );
const nudgeServo = useCallback( const nudgeServo = useCallback(
(delta = 0) => { (delta = 0) => {
if (ptzControls.isActive) {
/*
A PTZ camera has no absolute browser-side servo angle. Treat a nudge
as held zoom direction and, importantly, preserve zero as an explicit
release. The previous fallback converted nudgeServo(0) into a positive
default step, so releasing the mobile zoom button could zoom in again.
*/
ptzControls.setZoomIntent(delta);
recordControlIntent();
return;
}
const config = pipeline.servoConfig; const config = pipeline.servoConfig;
if (!config && !ptzControls.isActive) return; if (!config) return;
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1; const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
const baseline = const baseline =
typeof servoAngleRef.current === 'number' typeof servoAngleRef.current === 'number'
@@ -431,7 +395,28 @@ export function ControlSystemProvider({ children }) {
: 0; : 0;
setServoAngle(baseline + step); setServoAngle(baseline + step);
}, },
[pipeline.servoConfig, ptzControls.isActive, setServoAngle], [pipeline.servoConfig, ptzControls, recordControlIntent, setServoAngle],
);
const setCameraAxisIntent = useCallback(
(direction = 0) => {
/*
Keyboard, touch, and gamepad all need an explicit way to say that a
camera axis returned to neutral. Rover servos remain position/nudge
based, so returning false tells those callers to continue through their
existing rover implementation without introducing PTZ rules there.
*/
if (!ptzControls.isActive) return false;
ptzControls.setZoomIntent(direction);
/*
Do not dispatch recordControlIntent here. Gamepads publish their neutral
and held axes every animation frame; the PTZ adapter deduplicates state
and owns its 250 ms heartbeat, so a React reducer update per frame would
add churn without representing a new user action.
*/
return true;
},
[ptzControls],
); );
const goServoHome = useCallback(() => { const goServoHome = useCallback(() => {
@@ -730,6 +715,7 @@ export function ControlSystemProvider({ children }) {
setAuxMotors, setAuxMotors,
setServoAngle, setServoAngle,
nudgeServo, nudgeServo,
setCameraAxisIntent,
goServoHome, goServoHome,
setCameraPrecisionMode, setCameraPrecisionMode,
runMacro, runMacro,
@@ -757,6 +743,7 @@ export function ControlSystemProvider({ children }) {
setAuxMotors, setAuxMotors,
setServoAngle, setServoAngle,
nudgeServo, nudgeServo,
setCameraAxisIntent,
goServoHome, goServoHome,
setCameraPrecisionMode, setCameraPrecisionMode,
runMacro, runMacro,
@@ -53,6 +53,7 @@ export default function GamepadInputManager() {
setDriveVector, setDriveVector,
setAuxMotors, setAuxMotors,
setServoAngle, setServoAngle,
setCameraAxisIntent,
runMacro, runMacro,
toggleHeadlight, toggleHeadlight,
toggleLaser, toggleLaser,
@@ -173,6 +174,7 @@ export default function GamepadInputManager() {
runMacro, runMacro,
saveGamepadSettings, saveGamepadSettings,
setAuxMotors, setAuxMotors,
setCameraAxisIntent,
setDriveVector, setDriveVector,
setMode, setMode,
setServoAngle, setServoAngle,
@@ -187,6 +189,9 @@ export default function GamepadInputManager() {
if (!latest) return; if (!latest) return;
const activePad = pickActivePad(hubState.pads, latest.activeSignature); const activePad = pickActivePad(hubState.pads, latest.activeSignature);
if (!activePad) { if (!activePad) {
// A disconnected controller cannot deliver a final neutral axis sample.
// Publish it here so PTZ zoom never depends on the browser doing so.
latest.setCameraAxisIntent(0);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) { if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR; lastVectorRef.current = ZERO_VECTOR;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE }); latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
@@ -204,6 +209,9 @@ export default function GamepadInputManager() {
} }
if (isTextEntryActive()) { if (isTextEntryActive()) {
// Entering text blocks gamepad control immediately, including a held
// camera axis that otherwise would keep its last PTZ zoom direction.
latest.setCameraAxisIntent(0);
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) { if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
lastVectorRef.current = ZERO_VECTOR; lastVectorRef.current = ZERO_VECTOR;
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE }); latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
@@ -302,7 +310,14 @@ export default function GamepadInputManager() {
handleButtonEdge('laserToggle', false); handleButtonEdge('laserToggle', false);
} }
if (Math.abs(outputs.cameraAxis) > 0.001) { /*
PTZ zoom consumes the live signed gamepad axis, including its zero
position, so releasing the stick is an explicit stop instead of merely
ending calls to the old servo updater. Rover camera servos return false
here and continue through their established absolute/velocity mapping.
*/
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) {
handleCameraAxis(outputs.cameraAxis, profile.calibration); handleCameraAxis(outputs.cameraAxis, profile.calibration);
} }
@@ -90,6 +90,7 @@ export default function KeyboardInputManager() {
setDriveVector, setDriveVector,
setAuxMotors, setAuxMotors,
nudgeServo, nudgeServo,
setCameraAxisIntent,
runMacro, runMacro,
stopAllMotion, stopAllMotion,
registerInputState, registerInputState,
@@ -215,6 +216,13 @@ export default function KeyboardInputManager() {
const ensureServoLoop = useCallback(() => { const ensureServoLoop = useCallback(() => {
const direction = computeServoDirection(); const direction = computeServoDirection();
if (direction === 0) { if (direction === 0) {
/*
Keyup must publish a real neutral PTZ zoom intent before the repeat loop
disappears. Rover servos ignore this generic axis release and retain
their existing nudge behavior because setCameraAxisIntent returns false
whenever PTZ is not the active target.
*/
latestRef.current?.setCameraAxisIntent(0);
stopServoLoop(); stopServoLoop();
return; return;
} }
@@ -235,7 +243,9 @@ export default function KeyboardInputManager() {
const tokensSnapshot = new Set(activeTokensRef.current); const tokensSnapshot = new Set(activeTokensRef.current);
const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap); const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap);
const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep; const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep;
if (!latest.setCameraAxisIntent(nextDirection)) {
latest.nudgeServo(nextDirection * servoStep); latest.nudgeServo(nextDirection * servoStep);
}
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs); servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
}; };
servoIntervalRef.current = setTimeout(tick, 0); servoIntervalRef.current = setTimeout(tick, 0);
@@ -394,6 +404,7 @@ export default function KeyboardInputManager() {
servoRepeatMs, servoRepeatMs,
servoStep, servoStep,
setAuxMotors, setAuxMotors,
setCameraAxisIntent,
setCameraPrecisionMode, setCameraPrecisionMode,
setDriveVector, setDriveVector,
setMicPttActive, setMicPttActive,
+54 -179
View File
@@ -1,8 +1,9 @@
// Overcurrent Limiter Hook/Utility // Overcurrent Protection View Hook
// Purpose: Applies client-side overcurrent guard logic to reduce harmful command spikes. Scope: Tracks limiter state and exposes gated dispatch behavior to controls. // Purpose: Adapts server-authoritative protection telemetry for existing control and HUD consumers.
import { useEffect, useMemo, useRef, useState } from 'react'; // Scope: Contains no protection timers or command scaling; enforcement belongs exclusively to the server service.
import { useMemo } from 'react';
import { useTelemetrySelector } from '../context/TelemetryContext.jsx'; import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../context/telemetryViews.js';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionSelector } from '../context/SessionContext.jsx';
export const OVERCURRENT_GROUPS = [ export const OVERCURRENT_GROUPS = [
@@ -10,189 +11,63 @@ export const OVERCURRENT_GROUPS = [
{ key: 'aux', motors: ['mainBrush', 'sideBrush'] }, { key: 'aux', motors: ['mainBrush', 'sideBrush'] },
]; ];
export const DEFAULT_OVERCURRENT_LIMITS = { const EMPTY_PROTECTION = Object.freeze({
downRatePerSec: 0.4, status: 'idle',
upRatePerSec: 0.5, bypassed: false,
releaseDelaySec: 2.5, drive: Object.freeze({ cap: 1, blocked: false, requiresNeutral: false, stopReason: null }),
outputRateMs: 250, motors: Object.freeze({}),
}; config: Object.freeze({}),
});
const RECOVERED_CAP_THRESHOLD = 0.999; function selectOvercurrentProtection(frame) {
return frame?.overcurrentProtection || EMPTY_PROTECTION;
function createInitialCaps() {
return OVERCURRENT_GROUPS.reduce((acc, group) => {
acc[group.key] = { cap: 1, clearSec: 0 };
return acc;
}, {});
} }
function clampUnit(value) { export function useOvercurrentLimiter(roverId) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(1, value));
}
export function useOvercurrentLimiter(roverId, options = {}) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const overcurrentFlags = useTelemetrySelector(roverId, selectOvercurrentFlags, overcurrentFlagsEqual); const protection = useTelemetrySelector(roverId, selectOvercurrentProtection);
const config = useMemo(
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
[options.config],
);
const [caps, setCaps] = useState(() => createInitialCaps());
const lastTickRef = useRef(0);
const flagsRef = useRef(overcurrentFlags);
useEffect(() => {
flagsRef.current = overcurrentFlags || {};
}, [overcurrentFlags]);
useEffect(() => {
setCaps(createInitialCaps());
lastTickRef.current = 0;
}, [roverId]);
const hasAnyOvercurrent = useMemo(
() => OVERCURRENT_GROUPS.some((group) => group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]))),
[overcurrentFlags],
);
const needsRecoveryTick = useMemo(
() =>
Object.values(caps || {}).some((entry) => {
/*
Recovery intentionally completes at a tiny tolerance below exactly 1.
The limiter advances in timed floating-point steps, so requiring an
exact 1 can strand the UI at a visually empty bar while the limiter is
still technically active at a value like 0.9992.
*/
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
return cap < RECOVERED_CAP_THRESHOLD;
}),
[caps],
);
const shouldTick = Boolean(roverId) && (hasAnyOvercurrent || needsRecoveryTick);
useEffect(() => {
if (!shouldTick) return undefined;
lastTickRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
const interval = setInterval(() => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const deltaMs = Math.max(0, now - lastTickRef.current);
lastTickRef.current = now;
const deltaSec = Math.min(0.25, deltaMs / 1000);
if (deltaSec <= 0) return;
setCaps((prev) => {
let changed = false;
const next = {};
const downRate = Number.isFinite(config.downRatePerSec) ? Math.max(0, config.downRatePerSec) : 0;
const upRate = Number.isFinite(config.upRatePerSec) ? Math.max(0, config.upRatePerSec) : 0;
const releaseDelay = Number.isFinite(config.releaseDelaySec) ? Math.max(0, config.releaseDelaySec) : 0;
OVERCURRENT_GROUPS.forEach((group) => {
const prevEntry = prev[group.key] || { cap: 1, clearSec: 0 };
const prevCap = Number.isFinite(prevEntry.cap) ? prevEntry.cap : 1;
const prevClear = Number.isFinite(prevEntry.clearSec) ? prevEntry.clearSec : 0;
const over = group.motors.some((motor) => Boolean(flagsRef.current?.[motor]));
const nextClear = over ? 0 : prevClear + deltaSec;
const allowRecover = !over && nextClear >= releaseDelay;
const rawNextCap = clampUnit(
over ? prevCap - downRate * deltaSec : allowRecover ? prevCap + upRate * deltaSec : prevCap,
);
/*
Once recovery reaches the shared completion threshold, snap the cap
to exactly full strength. This keeps the tick loop, command scaling,
and HUD visibility from disagreeing over a harmless fractional tail.
*/
const nextCap = !over && rawNextCap >= RECOVERED_CAP_THRESHOLD ? 1 : rawNextCap;
if (Math.abs(nextCap - prevCap) > 0.0001 || Math.abs(nextClear - prevClear) > 0.0001) {
changed = true;
}
next[group.key] = { cap: nextCap, clearSec: nextClear };
});
return changed ? next : prev;
});
}, 100);
return () => clearInterval(interval);
}, [config.downRatePerSec, config.releaseDelaySec, config.upRatePerSec, roverId, shouldTick]);
const scales = useMemo(() => {
const perGroup = OVERCURRENT_GROUPS.reduce((acc, group) => {
const entry = caps?.[group.key];
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
acc[group.key] = clampUnit(cap);
return acc;
}, {});
return {
perGroup,
drive: {
left: perGroup.drive ?? 1,
right: perGroup.drive ?? 1,
},
aux: {
main: perGroup.aux ?? 1,
side: perGroup.aux ?? 1,
vacuum: 1,
},
};
}, [caps]);
const overcurrent = useMemo(() => {
const motors = {};
OVERCURRENT_GROUPS.forEach((group) => {
group.motors.forEach((motor) => {
motors[motor] = Boolean(overcurrentFlags?.[motor]);
});
});
const groups = OVERCURRENT_GROUPS.reduce((acc, group) => {
acc[group.key] = group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]));
return acc;
}, {});
return { motors, groups };
}, [overcurrentFlags]);
const adminImmune = role === 'admin' || role === 'lockdown'; const adminImmune = role === 'admin' || role === 'lockdown';
return useMemo( return useMemo(() => {
() => ({ const motors = protection?.motors || {};
caps, const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
overcurrent, const mainCap = Number.isFinite(motors?.mainBrush?.cap) ? motors.mainBrush.cap : 1;
scales, const sideCap = Number.isFinite(motors?.sideBrush?.cap) ? motors.sideBrush.cap : 1;
const auxCap = Math.min(mainCap, sideCap);
const motorFlags = OVERCURRENT_GROUPS.reduce((result, group) => {
group.motors.forEach((motor) => {
result[motor] = Boolean(motors?.[motor]?.overcurrent);
});
return result;
}, {});
const groupFlags = OVERCURRENT_GROUPS.reduce((result, group) => {
result[group.key] = group.motors.some((motor) => motorFlags[motor]);
return result;
}, {});
/* /*
HUD and resend behavior should only remain active while the limiter has The compatibility-shaped fields keep existing control-context consumers
meaningful scale left to recover. Using the same threshold as the tick simple while every value now comes from the same server snapshot. There
loop prevents an empty overcurrent overlay from staying mounted after is deliberately no local recovery loop: a stale or disconnected browser
recovery has already stopped. must never invent a safer state than the server actually calculated.
*/ */
isActive: return {
(scales?.drive?.left ?? 1) < RECOVERED_CAP_THRESHOLD || ...protection,
(scales?.drive?.right ?? 1) < RECOVERED_CAP_THRESHOLD || caps: {
(scales?.aux?.main ?? 1) < RECOVERED_CAP_THRESHOLD || drive: { cap: driveCap },
(scales?.aux?.side ?? 1) < RECOVERED_CAP_THRESHOLD, aux: { cap: auxCap },
config, },
scales: {
perGroup: { drive: driveCap, aux: auxCap },
drive: { left: driveCap, right: driveCap },
aux: { main: mainCap, side: sideCap, vacuum: 1 },
},
overcurrent: { motors: motorFlags, groups: groupFlags },
isActive: protection?.status === 'limiting'
|| protection?.status === 'overcurrent'
|| protection?.status === 'stopped'
|| protection?.status === 'recovering',
adminImmune, adminImmune,
}),
[caps, overcurrent, scales, config, adminImmune],
);
}
export function applyDriveOvercurrentScale(speeds = {}, scales, adminImmune = false) {
if (adminImmune || !scales?.drive) return speeds;
const leftScale = typeof scales.drive.left === 'number' ? scales.drive.left : 1;
const rightScale = typeof scales.drive.right === 'number' ? scales.drive.right : 1;
if (leftScale >= 0.999 && rightScale >= 0.999) return speeds;
return {
left: Math.round((speeds.left ?? 0) * leftScale),
right: Math.round((speeds.right ?? 0) * rightScale),
};
}
export function applyAuxOvercurrentScale(values = {}, scales, adminImmune = false) {
if (adminImmune || !scales?.aux) return values;
const mainScale = typeof scales.aux.main === 'number' ? scales.aux.main : 1;
const sideScale = typeof scales.aux.side === 'number' ? scales.aux.side : 1;
const vacuumScale = typeof scales.aux.vacuum === 'number' ? scales.aux.vacuum : 1;
if (mainScale >= 0.999 && sideScale >= 0.999 && vacuumScale >= 0.999) return values;
return {
main: Math.round((values.main ?? 0) * mainScale),
side: Math.round((values.side ?? 0) * sideScale),
vacuum: Math.round((values.vacuum ?? 0) * vacuumScale),
}; };
}, [adminImmune, protection]);
} }
+132 -57
View File
@@ -4,7 +4,7 @@
// mobile, desktop, and gamepad inputs do not each learn camera-specific rules. // mobile, desktop, and gamepad inputs do not each learn camera-specific rules.
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 }; const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 };
const PTZ_SPEEDS = { const PTZ_SPEEDS = {
@@ -12,7 +12,11 @@ const PTZ_SPEEDS = {
medium: 0.5, medium: 0.5,
fast: 1, fast: 1,
}; };
const ZOOM_PULSE_MS = 220; // The TrackMix advertises a minimum ONVIF movement timeout of one second. A
// quarter-second browser heartbeat gives the server several opportunities to
// renew a genuinely held intent while still letting its watchdog distinguish a
// live control from a browser that disappeared without delivering a release.
const MOTION_HEARTBEAT_MS = 250;
function clampUnit(value) { function clampUnit(value) {
const number = Number(value) || 0; const number = Number(value) || 0;
@@ -98,10 +102,14 @@ function nextIrMode(currentMode) {
export function usePtzControlAdapter() { export function usePtzControlAdapter() {
const socket = useSocket(); const socket = useSocket();
const { ptzSpotlight, ptzIr } = useSessionActions();
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isActive = Boolean(ptz?.isOperator); const isActive = Boolean(ptz?.isOperator);
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP)); const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
const zoomStopTimerRef = useRef(null); const desiredMotionRef = useRef(PTZ_STOP);
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
const zoomIntentRef = useRef(0);
const heartbeatTimerRef = useRef(null);
const emitPtz = useCallback( const emitPtz = useCallback(
(eventName, payload = {}) => { (eventName, payload = {}) => {
@@ -111,18 +119,13 @@ export function usePtzControlAdapter() {
[isActive, socket], [isActive, socket],
); );
const stopMotion = useCallback(() => { const clearHeartbeat = useCallback(() => {
if (zoomStopTimerRef.current) { if (!heartbeatTimerRef.current) return;
clearTimeout(zoomStopTimerRef.current); clearInterval(heartbeatTimerRef.current);
zoomStopTimerRef.current = null; heartbeatTimerRef.current = null;
} }, []);
const stopSignature = payloadSignature(PTZ_STOP);
if (lastMotionSignatureRef.current === stopSignature) return;
lastMotionSignatureRef.current = stopSignature;
emitPtz('ptzCamera:stop');
}, [emitPtz]);
const sendMotion = useCallback( const publishMotion = useCallback(
(payload, options = {}) => { (payload, options = {}) => {
if (!isActive) return false; if (!isActive) return false;
const nextPayload = { const nextPayload = {
@@ -131,65 +134,111 @@ export function usePtzControlAdapter() {
zoom: clampUnit(payload?.zoom), zoom: clampUnit(payload?.zoom),
}; };
const nextSignature = payloadSignature(nextPayload); const nextSignature = payloadSignature(nextPayload);
desiredMotionRef.current = nextPayload;
if (isIdlePayload(nextPayload)) {
clearHeartbeat();
} else if (!heartbeatTimerRef.current) {
/*
Movement is renewed from the complete desired vector, not from the
individual input event that happened to start it. This is what makes
pan/tilt and zoom independent: every heartbeat describes all axes as
they should be now, and no delayed zoom pulse can resurrect an older
direction after a release.
*/
heartbeatTimerRef.current = setInterval(() => {
const current = desiredMotionRef.current;
if (isIdlePayload(current)) {
clearHeartbeat();
return;
}
emitPtz('ptzCamera:motion', current);
}, MOTION_HEARTBEAT_MS);
}
if (!options.force && lastMotionSignatureRef.current === nextSignature) return true; if (!options.force && lastMotionSignatureRef.current === nextSignature) return true;
lastMotionSignatureRef.current = nextSignature; lastMotionSignatureRef.current = nextSignature;
if (isIdlePayload(nextPayload)) { // Zero is a first-class desired state. The server translates the complete
emitPtz('ptzCamera:stop'); // idle vector into ONVIF Stop inside the same serialized command stream as
} else { // movement, which prevents separate move/stop handlers from racing.
emitPtz('ptzCamera:move', nextPayload); emitPtz('ptzCamera:motion', nextPayload);
}
return true; return true;
}, },
[emitPtz, isActive], [clearHeartbeat, emitPtz, isActive],
);
const stopMotion = useCallback(
(options = {}) => {
// A global stop deliberately clears every axis. Safety/lifecycle callers
// use force so the server receives a fresh stop even when the browser's
// local signature already says it is idle after a dropped connection.
panTiltIntentRef.current = { pan: 0, tilt: 0 };
zoomIntentRef.current = 0;
return publishMotion(PTZ_STOP, { force: Boolean(options.force) });
},
[publishMotion],
); );
const applyDriveVector = useCallback( const applyDriveVector = useCallback(
(vector, meta = {}) => { (vector, meta = {}) => {
if (!isActive) return false; if (!isActive) return false;
sendMotion(buildPanTiltPayload(vector, meta)); const panTilt = buildPanTiltPayload(vector, meta);
panTiltIntentRef.current = {
pan: panTilt.pan,
tilt: panTilt.tilt,
};
/*
ONVIF continuous movement accepts pan, tilt, and zoom in one command.
Preserve the current zoom intent when a direction update arrives so a
keyboard or touch event on one axis cannot erase another held axis.
*/
publishMotion({
...panTiltIntentRef.current,
zoom: zoomIntentRef.current,
});
return true; return true;
}, },
[isActive, sendMotion], [isActive, publishMotion],
); );
const pulseZoom = useCallback( const setZoomIntent = useCallback(
(direction) => { (direction) => {
if (!isActive) return false; if (!isActive) return false;
const sign = axisSign(direction); const numeric = clampUnit(direction);
if (!sign) { const sign = axisSign(numeric);
stopMotion();
return true;
}
/* /*
Zoom is different from pan/tilt because it is driven by repeated nudge PTZ zoom is a held velocity, not a rover servo nudge. Convert the input
events from existing camera controls. Force each pulse through even when magnitude to the same precision/normal tiers used for pan and tilt, then
the payload is identical, otherwise holding "camera up" only sends the retain it until the input surface explicitly publishes zero. The shared
first zoom command and every later nudge is de-duped away. heartbeat renews that state; there are no per-button repeat or delayed
stop timers left to race with pointer/key release.
*/ */
sendMotion({ pan: 0, tilt: 0, zoom: sign * PTZ_SPEEDS.medium }, { force: true }); const speed = !sign ? 0 : Math.abs(numeric) <= 0.45 ? PTZ_SPEEDS.slow : PTZ_SPEEDS.medium;
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current); zoomIntentRef.current = sign * speed;
/* publishMotion({
Existing rover camera controls are nudge/slider based, not hold-based. ...panTiltIntentRef.current,
Treat each nudge as a short PTZ zoom pulse, then stop from the adapter zoom: zoomIntentRef.current,
so delayed stop behavior is owned by the camera layer only. });
*/
zoomStopTimerRef.current = setTimeout(() => {
zoomStopTimerRef.current = null;
stopMotion();
}, ZOOM_PULSE_MS);
return true; return true;
}, },
[isActive, sendMotion, stopMotion], [isActive, publishMotion],
); );
const setSpotlight = useCallback( const setSpotlight = useCallback(
(nextOn) => { (nextOn) => {
if (!isActive) return false; if (!isActive) return false;
const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light); const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light);
emitPtz('ptzCamera:spotlight', { state: desiredOn ? 1 : 0 }); /*
Lighting keybinds should use the exact acknowledged command action as
the visible PTZ buttons. Movement remains fire-and-forget because it is
continuous and high frequency, but a discrete light toggle benefits
from the existing authorization/error contract and must not maintain a
second socket-only behavior merely because its source is a keybind.
*/
ptzSpotlight({ state: desiredOn ? 1 : 0 }).catch(() => {});
return true; return true;
}, },
[emitPtz, isActive, ptz?.light], [isActive, ptz?.light, ptzSpotlight],
); );
const setIr = useCallback( const setIr = useCallback(
@@ -198,27 +247,53 @@ export function usePtzControlAdapter() {
const desiredState = typeof nextOn === 'boolean' const desiredState = typeof nextOn === 'boolean'
? (nextOn ? 'On' : 'Off') ? (nextOn ? 'On' : 'Off')
: nextIrMode(ptz?.ir?.state); : nextIrMode(ptz?.ir?.state);
emitPtz('ptzCamera:ir', { state: desiredState }); // Match the button path for the same reason as spotlight above. The
// shared laser key continues to select IR; only its transport is unified.
ptzIr({ state: desiredState }).catch(() => {});
return true; return true;
}, },
[emitPtz, isActive, ptz?.ir?.state], [isActive, ptz?.ir?.state, ptzIr],
); );
useEffect(() => { useEffect(() => {
if (isActive) return undefined; if (isActive) return undefined;
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP); lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
if (zoomStopTimerRef.current) { desiredMotionRef.current = PTZ_STOP;
clearTimeout(zoomStopTimerRef.current); panTiltIntentRef.current = { pan: 0, tilt: 0 };
zoomStopTimerRef.current = null; zoomIntentRef.current = 0;
} clearHeartbeat();
return undefined; return undefined;
}, [isActive]); }, [clearHeartbeat, isActive]);
useEffect(() => {
if (!isActive) return undefined;
const forceSafetyStop = () => stopMotion({ force: true });
const handleVisibility = () => {
if (document.visibilityState === 'hidden') forceSafetyStop();
};
/*
Input components handle ordinary pointer/key releases, but the adapter is
the only layer guaranteed to see every PTZ control surface. Centralizing
browser lifecycle stops here covers touch, keyboard, and gamepad equally
when a tab hides, a window blurs, or mobile navigation fires pagehide.
*/
window.addEventListener('blur', forceSafetyStop);
window.addEventListener('pagehide', forceSafetyStop);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('blur', forceSafetyStop);
window.removeEventListener('pagehide', forceSafetyStop);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [isActive, stopMotion]);
useEffect( useEffect(
() => () => { () => () => {
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current); clearHeartbeat();
}, },
[], [clearHeartbeat],
); );
return useMemo( return useMemo(
@@ -226,11 +301,11 @@ export function usePtzControlAdapter() {
isActive, isActive,
state: ptz, state: ptz,
applyDriveVector, applyDriveVector,
pulseZoom, setZoomIntent,
setSpotlight, setSpotlight,
setIr, setIr,
stopMotion, stopMotion,
}), }),
[applyDriveVector, isActive, ptz, pulseZoom, setIr, setSpotlight, stopMotion], [applyDriveVector, isActive, ptz, setIr, setSpotlight, setZoomIntent, stopMotion],
); );
} }
+8 -1
View File
@@ -6,7 +6,8 @@ import CardFrame from '../components/CardFrame/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx'; import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionSelector } from '../context/SessionContext.jsx';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js'; import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
import { pageBackgroundClass } from '../themeFlags.js'; import { useSettingsNamespace } from '../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../themes/index.js';
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx'; import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
function isLockdownAdminRole(role) { function isLockdownAdminRole(role) {
@@ -17,6 +18,12 @@ export default function DatabaseAdminApp() {
useUserIdentitySync({ identitySurface: 'passive' }); useUserIdentitySync({ identitySurface: 'passive' });
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const connected = useSessionSelector((state) => state.connected); const connected = useSessionSelector((state) => state.connected);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
// Database cards use the same narrow seams as the driver page, so honoring the shared browser
// preference here keeps the existing route-level background behavior while making it dynamic.
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
const isLockdownAdmin = isLockdownAdminRole(role); const isLockdownAdmin = isLockdownAdminRole(role);
const isLoggedInAdmin = role === 'admin' || isLockdownAdmin; const isLoggedInAdmin = role === 'admin' || isLockdownAdmin;
@@ -36,8 +36,14 @@ export default function ServerDisplayContent() {
return ( return (
<div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100"> <div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100">
<div className="h-[8vh] min-h-[4rem] shrink-0"> <div className="flex h-[8vh] min-h-[4rem] shrink-0 overflow-hidden">
<OnlinePeopleStrip users={session?.users || []} /> <OnlinePeopleStrip users={session?.users || []} />
{/* The PTZ operator belongs in the same information band as the people
strip because it is another "who is active right now" signal. Making
it a flex sibling lets the badge reserve real layout space when it
appears, which pushes the scrolling strip left instead of covering
the rover or chat areas. */}
<DisplayPtzOperatorBadge />
</div> </div>
<div className="min-h-0 flex-[0.72]"> <div className="min-h-0 flex-[0.72]">
<DisplayRoverGrid roster={session?.roster || []} session={session} /> <DisplayRoverGrid roster={session?.roster || []} session={session} />
@@ -45,11 +51,6 @@ export default function ServerDisplayContent() {
<div className="min-h-0 flex-[1.28]"> <div className="min-h-0 flex-[1.28]">
<DisplayChatFeed /> <DisplayChatFeed />
</div> </div>
{/* Keep the PTZ operator visible on the room board without changing the
existing rover/chat layout. The badge is self-hiding when nobody owns
the camera, so the display remains exactly as sparse as before between
PTZ turns. */}
<DisplayPtzOperatorBadge />
<DisplayNoticeOverlay /> <DisplayNoticeOverlay />
<RewardRunOverlay /> <RewardRunOverlay />
{/* Display is spectator-like: every Discord-hosted replay should take over {/* Display is spectator-like: every Discord-hosted replay should take over
@@ -8,32 +8,31 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
export default function DisplayPtzOperatorBadge() { export default function DisplayPtzOperatorBadge() {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const operatorLabel = String(ptz?.operatorLabel || '').trim(); const operatorLabel = String(ptz?.operatorLabel || '').trim();
const visible = Boolean(ptz?.enabled && operatorLabel);
if (!ptz?.enabled || !operatorLabel) {
/*
The display should stay clean when nobody has the camera. Returning null
instead of showing "none" makes the badge behave like a popup: it appears
only for an active PTZ operator and disappears as soon as the turn ends.
*/
return null;
}
return ( return (
<aside <aside
className="pointer-events-none fixed bottom-[2vh] right-[2vw] z-[90] max-w-[42vw] border-4 border-sky-200 bg-sky-700 px-[1.4vw] py-[1vh] text-center" className={`pointer-events-none h-full shrink-0 overflow-hidden border-b border-l border-sky-200 bg-sky-700 transition-[width,opacity] duration-300 ease-out ${
aria-label={`PTZ operator ${operatorLabel}`} visible ? 'w-[min(34vw,34rem)] opacity-100' : 'w-0 opacity-0'
}`}
aria-hidden={!visible}
aria-label={visible ? `PTZ operator ${operatorLabel}` : undefined}
> >
{/* {/*
The label is deliberately short because /display is a room board, not a This is a flex-row segment instead of a fixed overlay so the online
control panel. The large name is the useful information from across the people marquee loses width when PTZ is active. That makes the badge feel
room, while the smaller prefix prevents the blue box from being mistaken like it enters from the right edge of the top bar while avoiding the
for a rover driver or chat message. previous problem where it covered content in the bottom-right corner.
*/} */}
<div className="text-7xl font-black tracking-normal text-sky-100"> <div className="flex h-full min-w-0 items-center justify-center gap-[1vw] px-[1.2vw] text-[clamp(2.1rem,5.1vh,5.6rem)] font-black leading-none tracking-normal text-white">
PTZ camera {/*
</div> The user explicitly requested uppercase "PTZ" here because the room
<div className="truncate text-9xl font-black leading-none text-white"> display needs a terse, instantly recognizable camera marker. The name
{operatorLabel} remains the larger variable part, and truncation prevents a long
nickname from resizing the bar or overlapping the scrolling strip.
*/}
<span className="shrink-0 text-sky-100">PTZ</span>
<span className="min-w-0 truncate">{operatorLabel}</span>
</div> </div>
</aside> </aside>
); );
@@ -68,7 +68,10 @@ export default function OnlinePeopleStrip({ users = [] }) {
)); ));
return ( return (
<div ref={viewportRef} className="relative h-full min-w-0 overflow-hidden border-b border-slate-800/80 bg-black"> <div
ref={viewportRef}
className="relative h-full min-w-0 flex-1 overflow-hidden border-b border-slate-800/80 bg-black"
>
<div <div
ref={trackRef} ref={trackRef}
className={classNames( className={classNames(
@@ -0,0 +1,52 @@
// Chat Message History Navigation
// Purpose: Gives each chat input Bash-style traversal over the shared persisted send history.
// Scope: Owns only draft/navigation state; it does not register global keys or interact with rover controls.
import { useCallback, useRef } from 'react';
import { useChatHistory } from '../context/ChatContext.jsx';
export default function useChatMessageHistoryNavigation() {
const { messageHistory } = useChatHistory();
const historyIndexRef = useRef(null);
const preservedDraftRef = useRef('');
const resetHistoryNavigation = useCallback(() => {
// Manual edits and successful sends begin a new navigation session. The
// current input value remains owned by the composer and is not changed here.
historyIndexRef.current = null;
preservedDraftRef.current = '';
}, []);
const navigateHistory = useCallback(
(direction, currentDraft) => {
if (!messageHistory.length) return null;
if (direction === 'previous') {
if (historyIndexRef.current === null) {
// Save the in-progress draft exactly once so ArrowDown can restore it
// after the user reaches the newest edge of history, like a shell.
preservedDraftRef.current = currentDraft;
historyIndexRef.current = messageHistory.length - 1;
} else {
historyIndexRef.current = Math.max(0, historyIndexRef.current - 1);
}
return messageHistory[historyIndexRef.current];
}
if (direction === 'next' && historyIndexRef.current !== null) {
if (historyIndexRef.current < messageHistory.length - 1) {
historyIndexRef.current += 1;
return messageHistory[historyIndexRef.current];
}
const preservedDraft = preservedDraftRef.current;
resetHistoryNavigation();
return preservedDraft;
}
return null;
},
[messageHistory, resetHistoryNavigation],
);
return { navigateHistory, resetHistoryNavigation };
}
+7 -22
View File
@@ -4,13 +4,11 @@ import { useSharedClock } from './useSharedClock.js';
export function useDriverVideoModePolicy(roverId) { export function useDriverVideoModePolicy(roverId) {
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {}); const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null); const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {}); const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const nonTurnVideoPolicy = useSessionSelector( const nonTurnSnapshotsActive = useSessionSelector(
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots', (state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
); );
const isTurnsMode = mode === 'turns'; const isTurnsMode = mode === 'turns';
/* /*
@@ -33,26 +31,13 @@ export function useDriverVideoModePolicy(roverId) {
const isNextDriver = Boolean(socketId && nextDriverId === socketId); const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null; const deadline = turnInfo?.deadline || null;
const msUntilTurn = deadline ? deadline - now : null; const msUntilTurn = deadline ? deadline - now : null;
const totalRovers = roster.length;
const totalDrivers = useMemo(() => {
const unique = new Set();
users.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const turnRoverId = String(entry?.roverId || '').trim();
const turnSocketId = String(entry?.socketId || '').trim();
if (!turnRoverId || !turnSocketId) return;
unique.add(turnSocketId);
});
return unique.size;
}, [users]);
/* /*
The server sends the bandwidth policy because the same rule is enforced in The server evaluates the global controllable-user threshold because that
video authorization. The hook only mirrors that policy so the UI avoids same decision is enforced in socket video tokens and MediaMTX auth. This
requesting live video when snapshots are the intended non-turn experience. hook only mirrors the active result so the browser does not request live
video when snapshots are already the authoritative non-turn outcome.
*/ */
const shouldUsePreviewByLoad = const shouldUsePreviewByLoad = nonTurnSnapshotsActive && isTurnsMode;
nonTurnVideoPolicy === 'snapshots' && isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow = const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0; isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = isTurnsMode && !isActiveDriver; const showNotTurnNotice = isTurnsMode && !isActiveDriver;
+33
View File
@@ -0,0 +1,33 @@
// Responsive Layout Mode Hook
// Purpose: Gives control-capable routes the same desktop, mobile-landscape,
// and mobile-portrait breakpoint policy.
// Scope: Classifies viewport geometry only; each route still owns its actual
// component arrangement so PTZ and rover controls can remain purpose-built.
import { useEffect, useState } from 'react';
function readLayoutMode() {
if (typeof window === 'undefined') return 'desktop';
if (window.innerWidth >= 1024) return 'desktop';
return window.innerWidth > window.innerHeight ? 'mobile-landscape' : 'mobile-portrait';
}
export default function useLayoutMode() {
const [mode, setMode] = useState(readLayoutMode);
useEffect(() => {
function updateMode() {
/*
Orientation changes are exposed as viewport resizes on the browsers
supported by this UI. Reading both dimensions here keeps the route
responsive without maintaining a second orientation event lifecycle.
*/
setMode(readLayoutMode());
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
+32 -19
View File
@@ -59,27 +59,40 @@ body {
} }
@layer components { @layer components {
.pride-page-bg { .inter-instance-overlay-frame {
background-color: #050505; /* The unscaled frame always stays inside the viewport on phones and on
background-image: desktop browsers that do not apply the larger presentation below. */
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0)), max-width: calc(100vw - 0.5rem);
repeating-linear-gradient(
45deg,
#5bcefa 0 1.818%,
#f5a9b8 1.818% 3.636%,
#ffffff 3.636% 5.455%,
#f5a9b8 5.455% 7.273%,
#5bcefa 7.273% 9.091%,
#e40303 9.091% 10.909%,
#ff8c00 10.909% 12.727%,
#ffed00 12.727% 14.545%,
#00a651 14.545% 16.364%,
#0066ff 16.364% 18.182%,
#9b2fae 18.182% 20%
);
background-attachment: fixed;
} }
.inter-instance-overlay-body {
max-height: 82vh;
}
@media (min-width: 1024px) {
.inter-instance-overlay-scale {
/*
`zoom` enlarges the complete interfaceincluding typography, controls,
spacing, and hit targetswhile participating in layout. A transform
would only enlarge the paint result and could overlap or clip sibling
content because the browser would still reserve the original size.
*/
zoom: 1.5;
}
.inter-instance-overlay-frame {
/* Reserve the inverse width before the 1.5x zoom so the final rendered
frame still fits inside the physical desktop viewport. */
max-width: calc((100vw - 0.5rem) / 1.5);
}
.inter-instance-overlay-body {
/* 54.6667vh becomes approximately 82vh after the 1.5x desktop zoom. */
max-height: 54.6667vh;
}
}
.panel { .panel {
@apply bg-black text-white p-0 rounded-md; @apply bg-black text-white p-0 rounded-md;
} }
+11
View File
@@ -4,6 +4,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import './index.css' import './index.css'
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
import './themes/styles/index.css'
import App from './App.jsx' import App from './App.jsx'
import { SocketProvider } from './context/SocketContext.jsx' import { SocketProvider } from './context/SocketContext.jsx'
import { SessionProvider } from './context/SessionContext.jsx' import { SessionProvider } from './context/SessionContext.jsx'
@@ -18,6 +21,7 @@ import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx' import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx' import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx' import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
@@ -37,6 +41,13 @@ createRoot(document.getElementById('root')).render(
<Route path="/display" element={<ServerDisplayApp />} /> <Route path="/display" element={<ServerDisplayApp />} />
<Route path="/scanner" element={<ScannerApp />} /> <Route path="/scanner" element={<ScannerApp />} />
<Route path="/database" element={<DatabaseAdminApp />} /> <Route path="/database" element={<DatabaseAdminApp />} />
{/*
PTZ is a separate route so the driver layout and its replay
panel are not mounted behind the camera controller. This
also makes orientation changes a PTZ layout concern instead
of a local overlay-open state owned by the driver page.
*/}
<Route path="/ptz" element={<PtzAppRoot />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ChatProvider> </ChatProvider>
+45
View File
@@ -0,0 +1,45 @@
// Dedicated PTZ Route Root
// Purpose: Mounts the PTZ controller as a real page with the same shared input
// and identity systems used by the driver page.
// Scope: Owns route-level providers and responsive selection only; camera state,
// queue policy, and the visible controller remain in the shared PTZ component.
import AlertFeed from '../components/AlertFeed/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { PtzControllerPage } from '../components/PtzCamera/index.jsx';
import {
ControlSystemProvider,
GamepadInputManager,
KeyboardInputManager,
} from '../controls/index.js';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import useIncomingInterInstanceTransfer from '../hooks/useIncomingInterInstanceTransfer.js';
import useLayoutMode from '../hooks/useLayoutMode.js';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
function PtzRouteContent() {
const layout = useLayoutMode();
/*
Navigating away from the driver route unmounts its identity hooks. The PTZ
route is still an active control surface, so it must keep the same driver
identity heartbeat alive instead of allowing the session to become passive
while someone operates or waits for the camera.
*/
useDefaultNickname();
useIncomingInterInstanceTransfer();
useUserIdentitySync({ identitySurface: 'driver' });
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<PtzControllerPage layout={layout} />
<AlertFeed />
<SocketConnectionPill />
</ControlSystemProvider>
);
}
export default function PtzAppRoot() {
return <PtzRouteContent />;
}
-9
View File
@@ -1,9 +0,0 @@
export const PRIDE_THEME_ENABLED = true;
export const pageBackgroundClass = PRIDE_THEME_ENABLED ? 'pride-page-bg' : 'bg-black';
// export const themeGapClass = PRIDE_THEME_ENABLED ? 'gap-1' : 'gap-0.5';
// export const themeStackClass = PRIDE_THEME_ENABLED ? 'space-y-1' : 'space-y-0.5';
// permanent smaller gaps i think is better probably
export const themeGapClass = 'gap-0.5';
export const themeStackClass = 'space-y-0.5';
+55
View File
@@ -0,0 +1,55 @@
// Page Theme Catalog
// Purpose: Defines persisted theme identities and their presentation order.
// Scope: Shared by the settings picker and every route that resolves a saved page theme.
export const DEFAULT_PAGE_THEME_KEY = 'progress-pride';
export const PAGE_THEME_OPTIONS = [
// Stable keys are stored in the browser settings cookie. Keeping presentation labels separate
// lets the UI wording improve later without invalidating anybody's saved preference.
{ key: 'progress-pride', label: 'Progress pride', className: 'page-theme-progress-pride' },
// Keep the high-contrast caution-stripe option near the default so it is reachable with one
// Next click as well as directly through the dropdown.
{ key: 'hazard-stripes', label: 'Hazard stripes', className: 'page-theme-hazard-stripes' },
{ key: 'pride-mix', label: 'Pride mix', className: 'page-theme-pride-mix' },
{ key: 'rainbow', label: 'Rainbow', className: 'page-theme-rainbow' },
{ key: 'transgender', label: 'Transgender', className: 'page-theme-transgender' },
{ key: 'bisexual', label: 'Bisexual', className: 'page-theme-bisexual' },
{ key: 'lesbian', label: 'Lesbian', className: 'page-theme-lesbian' },
{ key: 'nonbinary', label: 'Nonbinary', className: 'page-theme-nonbinary' },
{ key: 'pansexual', label: 'Pansexual', className: 'page-theme-pansexual' },
{ key: 'asexual', label: 'Asexual', className: 'page-theme-asexual' },
{ key: 'aurora', label: 'Aurora', className: 'page-theme-aurora' },
{ key: 'synthwave-grid', label: 'Synthwave grid', className: 'page-theme-synthwave-grid' },
{ key: 'neon-checker', label: 'Neon checker', className: 'page-theme-neon-checker' },
{ key: 'ocean-current', label: 'Ocean current', className: 'page-theme-ocean-current' },
{ key: 'ember-lattice', label: 'Ember lattice', className: 'page-theme-ember-lattice' },
{ key: 'starfield', label: 'Starfield', className: 'page-theme-starfield' },
{ key: 'vaporwave-sunset', label: 'Vaporwave sunset', className: 'page-theme-vaporwave-sunset' },
{ key: 'electric-circuit', label: 'Electric circuit', className: 'page-theme-electric-circuit' },
{ key: 'lava-flow', label: 'Lava flow', className: 'page-theme-lava-flow' },
{ key: 'deep-space-nebula', label: 'Deep-space nebula', className: 'page-theme-deep-space-nebula' },
{ key: 'holographic-waves', label: 'Holographic waves', className: 'page-theme-holographic-waves' },
{ key: 'mint-mosaic', label: 'Mint mosaic', className: 'page-theme-mint-mosaic' },
{ key: 'candy-swirl', label: 'Candy swirl', className: 'page-theme-candy-swirl' },
{ key: 'black', label: 'Black', className: 'page-theme-black' },
];
export function normalizePageThemeKey(value) {
// Cookie contents can outlive catalog changes or be edited by hand. Always resolve them to a
// known entry so the page, preview, and dropdown cannot disagree about the active selection.
return PAGE_THEME_OPTIONS.some((theme) => theme.key === value)
? value
: DEFAULT_PAGE_THEME_KEY;
}
export function getPageTheme(value) {
const normalizedKey = normalizePageThemeKey(value);
return PAGE_THEME_OPTIONS.find((theme) => theme.key === normalizedKey) || PAGE_THEME_OPTIONS[0];
}
export function getPageThemeClass(value) {
// The shared base class owns page-level behavior such as fixed positioning. The modifier only
// supplies artwork, which also lets the settings demonstration reuse the exact same theme.
return `page-theme ${getPageTheme(value).className}`;
}
+12
View File
@@ -0,0 +1,12 @@
// Theme Public API
// Purpose: Gives UI consumers one stable import path for catalog resolution and layout constants.
// Scope: Re-exports theme behavior while CSS remains independently loaded by the app entrypoint.
export {
DEFAULT_PAGE_THEME_KEY,
PAGE_THEME_OPTIONS,
getPageTheme,
getPageThemeClass,
normalizePageThemeKey,
} from './catalog.js';
export { themeGapClass, themeStackClass } from './layout.js';
+6
View File
@@ -0,0 +1,6 @@
// Theme-Aware Layout Constants
// Purpose: Keeps the narrow gap geometry used to reveal page themes consistent across layouts.
// Scope: Layout-only values; selecting a theme never changes panel dimensions or input placement.
export const themeGapClass = 'gap-0.5';
export const themeStackClass = 'space-y-0.5';
+26
View File
@@ -0,0 +1,26 @@
/* Page Theme Surfaces
* Purpose: Defines behavior shared by every page theme and by the settings demonstration.
* Scope: Canvas positioning and the explicit no-artwork fallback; individual artwork lives elsewhere.
*/
/* These selectors intentionally remain ordinary CSS. This stylesheet is processed separately from
* index.css, so placing them in a Tailwind layer would require duplicating Tailwind's directives. */
.page-theme {
/* A fixed canvas makes separated card gaps look like windows onto one continuous design.
Individual theme classes below supply only their artwork and fallback color. */
background-color: #050505;
background-attachment: fixed;
}
.page-theme-preview {
/* Preview artwork belongs to the demonstration box, not the browser viewport. Overriding the
page behavior here keeps the swatch representative while it moves inside a scroll panel. */
background-attachment: scroll;
background-position: center;
}
.page-theme-black {
/* An explicit quiet option belongs in the catalog rather than being a special-case toggle. */
background-color: #000000;
background-image: none;
}
+8
View File
@@ -0,0 +1,8 @@
/* Page Theme Stylesheet Entry
* Purpose: Loads theme surfaces and artwork as one explicit bundle from main.jsx.
* Scope: Import order keeps shared behavior first, followed by the two artwork families.
*/
@import './base.css';
@import './pride.css';
@import './patterns.css';
+207
View File
@@ -0,0 +1,207 @@
/* Pattern Page Themes
* Purpose: Contains decorative non-pride artwork optimized for narrow horizontal and vertical gaps.
* Scope: Static gradients and repeating patterns only; theme identity and ordering live in catalog.js.
*/
/* Ordinary CSS keeps the artwork independent of Tailwind's global component layer. main.jsx loads
* this theme bundle after index.css, giving the theme modifiers their original cascade position. */
.page-theme-aurora {
/* Overlapping oversized glows create slow color movement across the page without animation,
avoiding distracting motion and continuous painting behind live video. */
background-color: #03120f;
background-image:
radial-gradient(circle at 18% 24%, rgba(45, 212, 191, 0.95), transparent 34%),
radial-gradient(circle at 76% 18%, rgba(168, 85, 247, 0.9), transparent 38%),
radial-gradient(circle at 56% 82%, rgba(34, 197, 94, 0.88), transparent 42%),
linear-gradient(125deg, #020617, #0f172a 45%, #082f49);
background-size: 34rem 28rem, 38rem 32rem, 42rem 36rem, auto;
}
.page-theme-synthwave-grid {
/* Two repeating line layers form a broad grid; the magenta horizon glow prevents horizontal
gaps from reading as a single flat color when they cross between grid lines. Two-pixel grid
strokes remain crisp without bringing back the previous tiny graph-paper appearance. */
background-color: #090018;
background-image:
linear-gradient(rgba(34, 211, 238, 0.7) 2px, transparent 2px),
linear-gradient(90deg, rgba(236, 72, 153, 0.72) 2px, transparent 2px),
radial-gradient(ellipse at 50% 100%, #7e22ce 0, #1e1b4b 38%, #090018 72%);
background-size: 54px 54px, 54px 54px, auto;
}
.page-theme-neon-checker {
/* A conic gradient makes a crisp checker with no image asset and keeps intersections equally
interesting when exposed through horizontal or vertical card seams. The large tile size
turns each color into a strong block instead of a rapidly alternating micro-pattern. */
background-color: #111827;
background-image: conic-gradient(
from 45deg,
#06b6d4 0 25%,
#7c3aed 0 50%,
#ec4899 0 75%,
#111827 0
);
background-size: 84px 84px;
}
.page-theme-ocean-current {
/* Crossing translucent diagonals suggest moving water while remaining completely static. The
broad transparent intervals create long currents instead of tightly packed hatch marks. */
background-color: #082f49;
background-image:
repeating-linear-gradient(35deg, rgba(103, 232, 249, 0.72) 0 12px, transparent 12px 54px),
repeating-linear-gradient(-35deg, rgba(14, 116, 144, 0.72) 0 21px, transparent 21px 72px),
linear-gradient(90deg, #0c4a6e, #0891b2, #164e63);
}
.page-theme-ember-lattice {
/* Opposing narrow diagonals create bright crossings, with a dark red base keeping the theme
readable instead of overpowering the neutral cards around it. The enlarged repeat creates
occasional strong crossings rather than a dense mesh along every seam. */
background-color: #1c0704;
background-image:
repeating-linear-gradient(45deg, transparent 0 36px, rgba(251, 146, 60, 0.9) 36px 45px),
repeating-linear-gradient(-45deg, transparent 0 48px, rgba(239, 68, 68, 0.78) 48px 57px),
radial-gradient(circle at center, #7c2d12, #1c0704 70%);
}
.page-theme-starfield {
/* Offset radial layers provide stars at three scales. All layers repeat, so long PTZ and
driver pages never reveal an unpainted region while scrolling. Larger spacing and dots make
this read as a sparse starfield rather than fine visual noise inside the narrow gaps. */
background-color: #020617;
background-image:
radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 2px, transparent 3px),
radial-gradient(circle, rgba(125, 211, 252, 0.9) 0 3px, transparent 4px),
radial-gradient(circle, rgba(216, 180, 254, 0.8) 0 2px, transparent 3px),
linear-gradient(135deg, #020617, #172554 55%, #3b0764);
background-position: 0 0, 57px 81px, 123px 33px, 0 0;
background-size: 111px 111px, 183px 183px, 237px 237px, auto;
}
.page-theme-vaporwave-sunset {
/* A large horizon glow supplies the sunset while widely spaced scan lines add retro structure.
Both layers span enough distance to remain recognizable through narrow card seams instead of
collapsing into the fine television-static texture common to smaller vaporwave patterns. */
background-color: #2e1065;
background-image:
repeating-linear-gradient(
0deg,
transparent 0 24px,
rgba(103, 232, 249, 0.48) 24px 27px,
transparent 27px 54px
),
radial-gradient(circle at 50% 78%, #fda4af 0 14%, #f97316 14% 25%, transparent 25.5%),
linear-gradient(180deg, #172554 0%, #6b21a8 48%, #db2777 72%, #f97316 100%);
background-size: auto, 48rem 34rem, auto;
}
.page-theme-electric-circuit {
/* Offset horizontal and vertical traces form large circuit-like routes. Separate repeat sizes
keep their intersections irregular, while the dark navy base prevents the cyan and violet
traces from overpowering the cards that the pattern is meant to separate. */
background-color: #020617;
background-image:
repeating-linear-gradient(
90deg,
transparent 0 48px,
rgba(34, 211, 238, 0.9) 48px 52px,
transparent 52px 96px
),
repeating-linear-gradient(
0deg,
transparent 0 68px,
rgba(168, 85, 247, 0.86) 68px 72px,
transparent 72px 124px
),
radial-gradient(circle at center, #312e81 0, #0f172a 45%, #020617 78%);
}
.page-theme-lava-flow {
/* Oversized overlapping pools create broad molten channels without animation. Their offset
positions ensure horizontal and vertical seams cross different hot and cooled regions as
they travel around the page. */
background-color: #180503;
background-image:
radial-gradient(ellipse at 18% 28%, #facc15 0 8%, #f97316 9% 18%, #991b1b 24%, transparent 39%),
radial-gradient(ellipse at 76% 64%, #fb923c 0 10%, #dc2626 18%, #7f1d1d 29%, transparent 44%),
radial-gradient(ellipse at 48% 92%, #fde047 0 7%, #ea580c 17%, transparent 36%),
linear-gradient(135deg, #180503, #450a0a 50%, #1c0704);
background-size: 42rem 34rem, 48rem 39rem, 37rem 31rem, auto;
}
.page-theme-deep-space-nebula {
/* Large soft clouds distinguish this from the sparse Starfield theme. A few broad nebulae are
more useful in thin gaps than many tiny stars, and the static layers avoid continuous paint
work behind the live rover and PTZ video surfaces. */
background-color: #020617;
background-image:
radial-gradient(ellipse at 22% 38%, rgba(217, 70, 239, 0.92), transparent 34%),
radial-gradient(ellipse at 72% 24%, rgba(59, 130, 246, 0.9), transparent 38%),
radial-gradient(ellipse at 58% 82%, rgba(14, 165, 233, 0.72), transparent 36%),
radial-gradient(circle, rgba(255, 255, 255, 0.9) 0 2px, transparent 3px),
linear-gradient(125deg, #020617, #1e1b4b 52%, #3b0764);
background-position: center, center, center, 17px 31px, center;
background-size: 42rem 34rem, 48rem 38rem, 39rem 35rem, 173px 173px, auto;
}
.page-theme-holographic-waves {
/* Broad translucent diagonals cross over an iridescent base. Using two directions means both
horizontal and vertical gaps encounter gradual color shifts without resorting to small,
noisy rainbow repetitions. */
background-color: #164e63;
background-image:
repeating-linear-gradient(
32deg,
rgba(255, 255, 255, 0.4) 0 14px,
transparent 14px 82px
),
repeating-linear-gradient(
-38deg,
rgba(216, 180, 254, 0.38) 0 18px,
transparent 18px 104px
),
linear-gradient(110deg, #67e8f9, #c4b5fd 32%, #f9a8d4 60%, #86efac 100%);
}
.page-theme-hazard-stripes {
/* This intentionally uses a simple, very wide two-color repeat. The large bands make the
industrial warning motif immediately readable even though only a two-pixel slice is visible
between neighboring cards. Its bands use the same shared width as every pride stripe theme. */
background-color: #111111;
background-image: repeating-linear-gradient(
45deg,
#facc15 0 48px,
#171717 48px 96px
);
}
.page-theme-mint-mosaic {
/* A large conic tile creates chunky geometric blocks in cool mint, teal, navy, and cream. The
restrained palette offers a calmer alternative to Neon checker while retaining clear color
changes along both seam directions. */
background-color: #064e3b;
background-image: conic-gradient(
from 45deg,
#a7f3d0 0 25%,
#0f766e 0 50%,
#fef3c7 0 75%,
#164e63 0
);
background-size: 96px 96px;
}
.page-theme-candy-swirl {
/* Large repeating rings provide curved movement that the otherwise line-oriented catalog does
not have. Wide color stops keep the pastel rings bold and prevent them from turning into a
dense target pattern in the preview or real page gaps. */
background-color: #fdf2f8;
background-image: repeating-radial-gradient(
circle at 28% 34%,
#f9a8d4 0 28px,
#fef3c7 28px 56px,
#93c5fd 56px 84px,
#c4b5fd 84px 112px,
#f9a8d4 112px 140px
);
}
+124
View File
@@ -0,0 +1,124 @@
/* Pride Page Themes
* Purpose: Contains the broad, equal-width flag-inspired stripe artwork.
* Scope: Pride color sequences only; shared canvas behavior lives in base.css.
*/
/* Ordinary CSS keeps the artwork independent of Tailwind's global component layer. main.jsx loads
* this theme bundle after index.css, giving the theme modifiers their original cascade position. */
.page-theme-pride-mix {
/* Every solid stripe theme uses the same forty-eight-pixel band width. Keeping that measurement
shared makes browsing predictable: only the colors and sequence change, never the visual
density of the card-gap artwork. */
background-image: repeating-linear-gradient(
45deg,
#5bcefa 0 48px,
#f5a9b8 48px 96px,
#ffffff 96px 144px,
#f5a9b8 144px 192px,
#5bcefa 192px 240px,
#e40303 240px 288px,
#ff8c00 288px 336px,
#ffed00 336px 384px,
#00a651 384px 432px,
#0066ff 432px 480px,
#9b2fae 480px 528px
);
}
.page-theme-rainbow {
/* Equal broad diagonal bands keep the familiar six-color flag readable as color fields rather
than a dense ribbon when exposed through either horizontal or vertical card gaps. */
background-image: repeating-linear-gradient(
45deg,
#e40303 0 48px,
#ff8c00 48px 96px,
#ffed00 96px 144px,
#008026 144px 192px,
#004dff 192px 240px,
#750787 240px 288px
);
}
.page-theme-progress-pride {
/* The inclusive chevron colors are interleaved with the rainbow because a literal large
chevron would disappear inside narrow seams. Using the shared stripe width keeps this longer
sequence just as bold as the shorter flag themes instead of compressing it to fit one cycle. */
background-image: repeating-linear-gradient(
45deg,
#000000 0 48px,
#784f17 48px 96px,
#5bcefa 96px 144px,
#f5a9b8 144px 192px,
#ffffff 192px 240px,
#e40303 240px 288px,
#ff8c00 288px 336px,
#ffed00 336px 384px,
#008026 384px 432px,
#004dff 432px 480px,
#750787 480px 528px
);
}
.page-theme-transgender {
background-image: repeating-linear-gradient(
45deg,
#5bcefa 0 48px,
#f5a9b8 48px 96px,
#ffffff 96px 144px,
#f5a9b8 144px 192px,
#5bcefa 192px 240px
);
}
.page-theme-bisexual {
/* Equal bands deliberately prioritize consistency with the other selectable stripe themes.
The three-color identity remains clear while browsing no longer changes stripe density. */
background-image: repeating-linear-gradient(
45deg,
#d60270 0 48px,
#9b4f96 48px 96px,
#0038a8 96px 144px
);
}
.page-theme-lesbian {
background-image: repeating-linear-gradient(
45deg,
#d52d00 0 48px,
#ef7627 48px 96px,
#ff9a56 96px 144px,
#ffffff 144px 192px,
#d162a4 192px 240px,
#b55690 240px 288px,
#a30262 288px 336px
);
}
.page-theme-nonbinary {
background-image: repeating-linear-gradient(
45deg,
#fff430 0 48px,
#ffffff 48px 96px,
#9c59d1 96px 144px,
#2d2d2d 144px 192px
);
}
.page-theme-pansexual {
background-image: repeating-linear-gradient(
45deg,
#ff218c 0 48px,
#ffd800 48px 96px,
#21b1ff 96px 144px
);
}
.page-theme-asexual {
background-image: repeating-linear-gradient(
45deg,
#111111 0 48px,
#a3a3a3 48px 96px,
#ffffff 96px 144px,
#800080 144px 192px
);
}