mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd1fd1e167 | ||
|
|
eba4b1dc1d | ||
|
|
cacd125fcb | ||
|
|
8a4162683f | ||
|
|
387a5f47d7 | ||
|
|
5b6947d92c | ||
|
|
cd8f8816c9 | ||
|
|
b86eec88f8 | ||
|
|
512adfc1e0 | ||
|
|
fe33f27bd0 | ||
|
|
afe8ffc62e | ||
|
|
4e6e9e3021 | ||
|
|
f9461433af | ||
|
|
b7d421c489 | ||
|
|
c6b2843150 | ||
|
|
b451849c02 | ||
|
|
955f6f213d | ||
|
|
c9842d7ba5 | ||
|
|
d7fb15d891 | ||
|
|
1cd0e05b4a | ||
|
|
7927768731 | ||
|
|
d9cb0c76b6 | ||
|
|
351cb24458 | ||
|
|
679563862d | ||
|
|
8655cde0f1 | ||
|
|
12090f23be | ||
|
|
557b4b81a2 | ||
|
|
0add90714b | ||
|
|
fe64ec7758 | ||
|
|
10f71edaf1 | ||
|
|
e97d4056fa | ||
|
|
c228bb107f | ||
|
|
06aeca660b | ||
|
|
4bd228547a | ||
|
|
35561495b4 | ||
|
|
8a9205b5b7 | ||
|
|
a6c569ada4 | ||
|
|
0d352d326d | ||
|
|
7bc08af160 | ||
|
|
9177e53fbf | ||
|
|
5be5ad3b17 | ||
|
|
99bc00e96b | ||
|
|
002b174259 | ||
|
|
e28ccc5e66 | ||
|
|
efae430d65 | ||
|
|
0c9df78070 |
@@ -33,3 +33,4 @@ server/data/identity.sqlite
|
||||
server/data/barcode-games.json
|
||||
server/data/identity.sqlite-shm
|
||||
server/data/identity.sqlite-wal
|
||||
server/src/services/balanceBoardService/native/balance_board_worker
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+93
-6
@@ -3,6 +3,7 @@ package roverd
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
hostStatsInterval = 5 * time.Second
|
||||
hostStatsInterval = 1 * time.Second
|
||||
rootFilesystem = "/"
|
||||
)
|
||||
|
||||
@@ -63,7 +64,24 @@ type WiFiStats struct {
|
||||
TXBytes *uint64 `json:"txBytes,omitempty"`
|
||||
RXPackets *uint64 `json:"rxPackets,omitempty"`
|
||||
TXPackets *uint64 `json:"txPackets,omitempty"`
|
||||
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
|
||||
UploadMbps *float64 `json:"uploadMbps,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
|
||||
@@ -370,12 +388,81 @@ func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The interface is used only to ask iw about the active connection. It is
|
||||
// not copied into WiFiStats because the UI does not need to expose it.
|
||||
if err := enrichWiFiWithIW(ctx, iface, stats); err != nil {
|
||||
return stats, err
|
||||
// The interface is used only for local collection. It is not copied into
|
||||
// WiFiStats because the UI does not need to expose Linux device names.
|
||||
iwErr := enrichWiFiWithIW(ctx, iface, stats)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return stats, nil
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -531,14 +531,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
||||
}
|
||||
|
||||
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
|
||||
var previousNetworkSample *networkRateSample
|
||||
|
||||
send := func() bool {
|
||||
// Host stats are collected on demand so each outbound message describes
|
||||
// the current Pi state. Collection failures are encoded into the stats
|
||||
// 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{
|
||||
Type: "hostStats",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Stats: CollectHostStats(ctx),
|
||||
Stats: stats,
|
||||
}
|
||||
if err := writeJSON(ctx, conn, msg); err != nil {
|
||||
c.log.Printf("host stats send failed: %v", err)
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
- not allowed
|
||||
- snapshots
|
||||
- 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)
|
||||
- userThreshold (snapshots turn on when controllable users exceed this number)
|
||||
- external spectator video
|
||||
- snapshots (external spectators are only allowed snapshots)
|
||||
- live (external spectators can get full video)
|
||||
@@ -23,7 +24,9 @@
|
||||
```yaml
|
||||
bandwidthSavings:
|
||||
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
|
||||
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
|
||||
```
|
||||
|
||||
@@ -60,10 +60,16 @@ bandwidthSavings:
|
||||
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||
# notAllowed: every identity is limited to one driver tab
|
||||
multiTabProtection: "verifiedOnly"
|
||||
# Live video for users who are attached to a source but do not currently own
|
||||
# its active turn. "snapshots" saves upload bandwidth; "live" allows full
|
||||
# video whenever the normal mode/visibility rules allow it.
|
||||
nonTurnVideo: "snapshots"
|
||||
# Video for users who are attached to a source but do not currently own its
|
||||
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||
# whenever the normal mode/visibility rules allow it.
|
||||
nonTurnVideo:
|
||||
mode: "snapshots"
|
||||
# Snapshot mode activates only when controllable users exceed this number.
|
||||
# A controllable user is attached to a rover or PTZ as operator/queue, not a
|
||||
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
|
||||
# actually attached to a controllable source.
|
||||
userThreshold: 0
|
||||
# Live video for spectators outside the local network. Local spectators are
|
||||
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||
externalSpectatorVideo: "snapshots"
|
||||
@@ -171,6 +177,11 @@ kinect:
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
balanceBoard:
|
||||
# The server installer always prepares Bluetooth and the kernel driver. This
|
||||
# switch only starts the service and shows its small live-weight panel.
|
||||
enabled: false
|
||||
|
||||
buttonBox:
|
||||
enabled: false
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ require('./src/services/buttonBoxService');
|
||||
require('./src/services/barcodeScannerService');
|
||||
require('./src/services/barcodeGameService');
|
||||
require('./src/services/kinectService');
|
||||
require('./src/services/balanceBoardService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replayEngineV2');
|
||||
|
||||
@@ -16,6 +16,8 @@ MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
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
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -30,6 +32,8 @@ fi
|
||||
TARGET_USER="$SUDO_USER"
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
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"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
@@ -134,7 +138,11 @@ dnf install -y \
|
||||
gstreamer1-rtsp-server \
|
||||
libfreenect \
|
||||
libfreenect-devel \
|
||||
libusb1-devel >/dev/null
|
||||
libusb1-devel \
|
||||
bluez \
|
||||
wiiuse \
|
||||
wiiuse-devel \
|
||||
libcap >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
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"
|
||||
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
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||
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)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
@@ -266,8 +305,8 @@ EOF
|
||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=Multi-Roomba Rover control server
|
||||
After=network-online.target mediamtx.service
|
||||
Wants=network-online.target
|
||||
After=network-online.target mediamtx.service bluetooth.service
|
||||
Wants=network-online.target bluetooth.service
|
||||
|
||||
[Service]
|
||||
User=$TARGET_USER
|
||||
@@ -304,3 +343,5 @@ echo
|
||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||
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 "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
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-B8ElczOE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ZpgWUPKf.css">
|
||||
<script type="module" crossorigin src="/assets/index-C-g10Rjz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwjDTdpq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,7 +10,10 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
|
||||
|
||||
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||
multiTabProtection: 'verifiedOnly',
|
||||
nonTurnVideo: 'snapshots',
|
||||
nonTurnVideo: Object.freeze({
|
||||
mode: 'snapshots',
|
||||
userThreshold: 0,
|
||||
}),
|
||||
externalSpectatorVideo: 'snapshots',
|
||||
externalSpectatorAccess: 'on',
|
||||
});
|
||||
@@ -25,6 +28,23 @@ function normalizeEnum(value, allowed, 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()) {
|
||||
const raw = config.bandwidthSavings || {};
|
||||
return {
|
||||
@@ -33,11 +53,7 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||
MULTI_TAB_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||
),
|
||||
nonTurnVideo: normalizeEnum(
|
||||
raw.nonTurnVideo,
|
||||
VIDEO_MODES,
|
||||
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
|
||||
),
|
||||
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
|
||||
externalSpectatorVideo: normalizeEnum(
|
||||
raw.externalSpectatorVideo,
|
||||
VIDEO_MODES,
|
||||
@@ -72,8 +88,16 @@ function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } =
|
||||
return !isVerified && !isAdmin;
|
||||
}
|
||||
|
||||
function shouldUseSnapshotsForNonTurnVideo() {
|
||||
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots';
|
||||
function shouldUseSnapshotsForNonTurnVideo({ controllableUserCount = 0 } = {}) {
|
||||
const { nonTurnVideo } = getBandwidthSavingsPolicy();
|
||||
if (nonTurnVideo.mode !== 'snapshots') return false;
|
||||
/*
|
||||
The threshold is evaluated centrally so MediaMTX auth, socket-issued video
|
||||
tokens, PTZ authorization, and browser session state all agree. Using a
|
||||
strict greater-than comparison makes the configured value read like the
|
||||
maximum number of controllable users allowed before snapshots start.
|
||||
*/
|
||||
return Math.max(0, Number(controllableUserCount) || 0) > nonTurnVideo.userThreshold;
|
||||
}
|
||||
|
||||
function shouldUseSnapshotsForExternalSpectatorVideo() {
|
||||
|
||||
@@ -46,6 +46,7 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
const kinectConfig = config.kinect || {};
|
||||
const buttonBoxConfig = config.buttonBox || {};
|
||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||
const balanceBoardConfig = config.balanceBoard || {};
|
||||
const barcodeGamesConfig = config.barcodeGames || {};
|
||||
const socialsConfig = config.socials || {};
|
||||
const interInstanceConfig = config.interInstance || {};
|
||||
@@ -68,6 +69,10 @@ function buildFeatureFlags(config = loadConfig()) {
|
||||
kinect: asBoolean(kinectConfig.enabled),
|
||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||
barcodeScanner,
|
||||
// The worker performs its own runtime availability reporting. Advertising
|
||||
// the feature from the explicit config switch lets the UI show useful
|
||||
// commissioning and hardware-error states even before a board is paired.
|
||||
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||
lift: Boolean(
|
||||
homeAssistant &&
|
||||
|
||||
@@ -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
@@ -8,7 +8,7 @@ const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters
|
||||
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
||||
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||
const { runChatTextCommand } = require('./textCommands');
|
||||
const { isTextCommand, runChatTextCommand } = require('./textCommands');
|
||||
|
||||
function createHandlers({ sendSystemMessage }) {
|
||||
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||
@@ -53,21 +53,26 @@ function createHandlers({ sendSystemMessage }) {
|
||||
maybeSendAccessNotice(message, sendSystemMessage);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
|
||||
try {
|
||||
// Commands sent from site chat should still be visible as normal chat
|
||||
// messages. Running the command after broadcast preserves the user-visible
|
||||
// transcript while keeping permissions and command execution entirely on
|
||||
// the server.
|
||||
const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
|
||||
cb({ success: true, command: ranCommand });
|
||||
return;
|
||||
} 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;
|
||||
}
|
||||
const command = isTextCommand(clean);
|
||||
// Chat delivery is complete once validation, broadcast, and local side
|
||||
// effects above have succeeded. A command may wait on Home Assistant,
|
||||
// hardware, replay preparation, or an external transport, so tying the
|
||||
// socket acknowledgement to command completion leaves the browser's send
|
||||
// promise pending and makes its input state appear stuck. Acknowledge now;
|
||||
// command replies continue through the normal Rover bot message stream.
|
||||
cb({ success: true, command });
|
||||
|
||||
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 }) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const { isDeterred } = require('../verificationService');
|
||||
const logger = require('../../globals/logger').child('commandService');
|
||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||
const homeAssistantService = require('../homeAssistantService');
|
||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||
@@ -93,6 +94,31 @@ function issueCommand(roverId, payload) {
|
||||
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) {
|
||||
const pending = pendingCommands.get(msg.id);
|
||||
if (!pending) return;
|
||||
@@ -226,7 +252,7 @@ io.on('connection', (socket) => {
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
const payload = data ? { ...data } : {};
|
||||
let payload = data ? { ...data } : {};
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
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 });
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
if (shouldRecordTurnActivity(type, payload)) {
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
const { app } = require('../../globals/http');
|
||||
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 {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
@@ -78,6 +78,7 @@ module.exports = {
|
||||
setLightColor: runtimeEngine.setLightColor,
|
||||
setLightWhite: runtimeEngine.setLightWhite,
|
||||
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
|
||||
setRandomColorScene: runtimeEngine.setRandomColorScene,
|
||||
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
|
||||
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 = {}) {
|
||||
const meta = entityConfig.get(entityId);
|
||||
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
|
||||
@@ -498,6 +564,7 @@ function createRuntimeEngine(deps) {
|
||||
setLightColor,
|
||||
setLightWhite,
|
||||
setAllControllableEntitiesState,
|
||||
setRandomColorScene,
|
||||
setAllControllableEntitiesLockedOnWhite,
|
||||
setLightsLockedOn,
|
||||
toggleLightsLockedOn,
|
||||
|
||||
@@ -33,6 +33,34 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
|
||||
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') {
|
||||
await message.reply({
|
||||
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
|
||||
@@ -41,9 +69,35 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
|
||||
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') {
|
||||
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 },
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -93,9 +93,10 @@ function createCommandHandlers(deps) {
|
||||
return;
|
||||
}
|
||||
// Actions in this set can change operational safety or access policy, so
|
||||
// lockdown mode narrows them from normal admins to lockdown admins. Room
|
||||
// light locking belongs here because it can force the physical room lights
|
||||
// on and disables ordinary Home Assistant room controls for everyone else.
|
||||
// lockdown mode narrows them from normal admins to lockdown admins. Lights
|
||||
// is included because its lock/unlock subcommands change room policy. Its
|
||||
// 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 isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||
const CATEGORIES = {
|
||||
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
||||
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
|
||||
features: { title: 'Features', names: ['lift', 'neato'] },
|
||||
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] },
|
||||
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
|
||||
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' },
|
||||
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
|
||||
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
|
||||
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
|
||||
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' },
|
||||
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' },
|
||||
|
||||
@@ -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 approach—but not yet cross—the 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);
|
||||
});
|
||||
@@ -29,6 +29,16 @@ const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
const DEFAULT_ONVIF_PORT = 8000;
|
||||
const DEFAULT_PROFILE_TOKEN = '003';
|
||||
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
|
||||
// explicitly disables replay for the camera.
|
||||
const DEFAULT_REPLAY_ENABLED = true;
|
||||
@@ -92,6 +102,14 @@ let publisherStderrSyncTimer = null;
|
||||
let snapshotTimer = null;
|
||||
let spotlightVerifyTimer = null;
|
||||
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;
|
||||
const snapshotSubscribers = 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) {
|
||||
/*
|
||||
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;
|
||||
clearTurnTimer();
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1061,26 +1110,235 @@ function normalizePresetCreateName(rawName) {
|
||||
return name;
|
||||
}
|
||||
|
||||
async function move(socket, payload = {}) {
|
||||
requireOperator(socket);
|
||||
await initialize();
|
||||
const x = clampUnit(payload.pan ?? payload.x);
|
||||
const y = clampUnit(payload.tilt ?? payload.y);
|
||||
const zoom = clampUnit(payload.zoom);
|
||||
await callOnvif('continuousMove', {
|
||||
profileToken: state.profileToken,
|
||||
x,
|
||||
y,
|
||||
zoom,
|
||||
timeout: 1000,
|
||||
});
|
||||
return { ok: true };
|
||||
function normalizeMotionIntent(payload = {}) {
|
||||
return {
|
||||
pan: clampUnit(payload.pan ?? payload.x),
|
||||
tilt: clampUnit(payload.tilt ?? payload.y),
|
||||
zoom: clampUnit(payload.zoom),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true });
|
||||
return { ok: true };
|
||||
return queueMotionIntent(payload, 'operator-input');
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1105,9 +1363,10 @@ async function gotoPreset(socket, payload = {}) {
|
||||
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
|
||||
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', {
|
||||
profileToken: state.profileToken,
|
||||
/*
|
||||
@@ -1305,7 +1564,10 @@ function canRequestLiveVideo(socket) {
|
||||
*/
|
||||
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"
|
||||
participants even before they become operator. When non-turn video is set
|
||||
@@ -1444,18 +1706,16 @@ function registerSocketHandlers() {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:move', async (firstArg, secondArg) => {
|
||||
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await move(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('ptzCamera:stop', async (firstArg, secondArg) => {
|
||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||
try {
|
||||
cb(await stop(socket));
|
||||
/*
|
||||
Acknowledge acceptance of the newest desired state immediately. The
|
||||
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||
request latency so browser heartbeats cannot accumulate while waiting
|
||||
for a camera SOAP response.
|
||||
*/
|
||||
cb(acceptMotionIntent(socket, payload));
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
@@ -1577,6 +1837,7 @@ module.exports = {
|
||||
ptzCameraEvents: events,
|
||||
getPublicState,
|
||||
getChatTargetForSocket,
|
||||
getParticipantSocketIds,
|
||||
canSpeakThroughPtz,
|
||||
speakText,
|
||||
canRequestLiveVideo,
|
||||
|
||||
@@ -61,6 +61,25 @@ function validateSources(list = [], 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) {
|
||||
const id = String(assignment.roverId);
|
||||
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
|
||||
|
||||
@@ -6,6 +6,7 @@ const logger = require('../../globals/logger').child('roverManager');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { parseSensorFrame } = require('../../helpers/sensorDecoder');
|
||||
const odometerService = require('../odometerService');
|
||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||
const { MODES, getMode } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
@@ -179,6 +180,7 @@ const sensorPipeline = createSensorPipeline({
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
processOdometerFrame: odometerService.processSensorFrame,
|
||||
processOvercurrentTelemetry: overcurrentProtectionService.processTelemetry,
|
||||
isPrivateRecord,
|
||||
isPrivateOpen,
|
||||
getPrivateSafety,
|
||||
@@ -190,6 +192,18 @@ const sensorPipeline = createSensorPipeline({
|
||||
const { handleSensorFrame, applyPrivateDriveSafety } = sensorPipeline;
|
||||
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) {
|
||||
roverLifecycle.removeSocket(socket, disableSpectator);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function createSensorPipeline(deps) {
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
processOdometerFrame,
|
||||
processOvercurrentTelemetry,
|
||||
isPrivateRecord,
|
||||
isPrivateOpen,
|
||||
getPrivateSafety,
|
||||
@@ -551,6 +552,19 @@ function createSensorPipeline(deps) {
|
||||
};
|
||||
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);
|
||||
const hasDockInfo = decoded?.chargingSources != null;
|
||||
if (hasDockInfo) {
|
||||
@@ -563,8 +577,18 @@ function createSensorPipeline(deps) {
|
||||
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||
handlePrivateButtonHold(record, decoded);
|
||||
evaluatePrivateSafety(record, decoded);
|
||||
io.to(record.room).volatile.emit('sensorFrame', { roverId, frame, sensors: decoded });
|
||||
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
|
||||
io.to(record.room).volatile.emit('sensorFrame', {
|
||||
roverId,
|
||||
frame,
|
||||
sensors: decoded,
|
||||
overcurrentProtection,
|
||||
});
|
||||
managerEvents.emit('sensor', {
|
||||
roverId,
|
||||
sensors: decoded,
|
||||
batteryState: record.batteryState,
|
||||
overcurrentProtection,
|
||||
});
|
||||
evaluateDockGuard(record, decoded);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ const { getState: getHomeAssistantState, homeAssistantEvents } = require('../hom
|
||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||
const { getState: getKinectState, kinectEvents } = require('../kinectService');
|
||||
const {
|
||||
getState: getBalanceBoardState,
|
||||
balanceBoardEvents,
|
||||
} = require('../balanceBoardService');
|
||||
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
|
||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||
const {
|
||||
@@ -42,6 +46,7 @@ const { getFeatureFlags } = require('../../helpers/features');
|
||||
const {
|
||||
canUseExternalSpectatorAccess,
|
||||
getBandwidthSavingsPolicy,
|
||||
shouldUseSnapshotsForNonTurnVideo,
|
||||
} = require('../../helpers/bandwidthSavings');
|
||||
const {
|
||||
getFeatureState,
|
||||
@@ -79,12 +84,17 @@ function hasExternalSpectatorGrant(socket) {
|
||||
return Boolean(state?.external);
|
||||
}
|
||||
|
||||
function buildBandwidthSavingsSessionState(socket) {
|
||||
function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
|
||||
const policy = getBandwidthSavingsPolicy();
|
||||
const local = isLocalNetwork(getSocketIp(socket));
|
||||
const granted = hasExternalSpectatorGrant(socket);
|
||||
return {
|
||||
...policy,
|
||||
nonTurnVideo: {
|
||||
...policy.nonTurnVideo,
|
||||
controllableUserCount,
|
||||
snapshotsActive: shouldUseSnapshotsForNonTurnVideo({ controllableUserCount }),
|
||||
},
|
||||
/*
|
||||
These derived fields let browser routes make clear UI choices without
|
||||
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) {
|
||||
if (!socket) return null;
|
||||
const role = getRole(socket);
|
||||
@@ -124,19 +152,22 @@ function buildUserEntry(socket) {
|
||||
function buildSession(socket) {
|
||||
const overseerVote = getOverseerVoteStatus();
|
||||
const features = getFeatureFlags();
|
||||
const users = Array.from(io.sockets.sockets.values())
|
||||
const userEntries = Array.from(io.sockets.sockets.values())
|
||||
.map((sock) => buildUserEntry(sock))
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
/*
|
||||
PTZ is intentionally not a roverManager record, so the normal physical
|
||||
rover visibility filter would erase the user's PTZ chat target. Preserve
|
||||
it here because getPtzChatTargetForSocket already applied the PTZ access
|
||||
and queue/operator rules before buildUserEntry returned it.
|
||||
*/
|
||||
roverId: entry.roverId === PTZ_CAMERA_ID ? entry.roverId : filterVisibleRoverId(socket, entry.roverId),
|
||||
}));
|
||||
.filter(Boolean);
|
||||
const controllableUserCount = countControllableUsers(userEntries);
|
||||
const users = userEntries.map((entry) => ({
|
||||
...entry,
|
||||
/*
|
||||
PTZ is intentionally not a roverManager record, so the normal physical
|
||||
rover visibility filter would erase the user's PTZ chat target. Preserve
|
||||
it here because getPtzChatTargetForSocket already applied the PTZ access
|
||||
and queue/operator rules before buildUserEntry returned it.
|
||||
*/
|
||||
roverId: entry.roverId === PTZ_CAMERA_ID
|
||||
? entry.roverId
|
||||
: filterVisibleRoverId(socket, entry.roverId),
|
||||
}));
|
||||
const roster = roverManager.getRosterForSocket(socket);
|
||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
|
||||
@@ -148,7 +179,7 @@ function buildSession(socket) {
|
||||
role: getRole(socket),
|
||||
mode: getMode(),
|
||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket),
|
||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||
/*
|
||||
Features is the single UI contract for optional server capabilities. A
|
||||
disabled feature should be absent from navigation/layout decisions even
|
||||
@@ -170,6 +201,7 @@ function buildSession(socket) {
|
||||
neato: getNeatoState(),
|
||||
lift: getLiftState(),
|
||||
kinect: getKinectState(),
|
||||
balanceBoard: getBalanceBoardState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(socket),
|
||||
health: getHealthSnapshot(),
|
||||
@@ -370,6 +402,14 @@ kinectEvents.on('change', () => {
|
||||
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', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -196,6 +196,26 @@ function canDrive(roverId, socket) {
|
||||
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) {
|
||||
if (!socketId) return false;
|
||||
const queue = driverQueues.get(roverId);
|
||||
@@ -410,6 +430,7 @@ module.exports = {
|
||||
driverRemoved,
|
||||
cleanupRover,
|
||||
canDrive,
|
||||
canRequestLiveVideo,
|
||||
isQueuedDriver,
|
||||
getActiveDrivers,
|
||||
turnEvents,
|
||||
|
||||
@@ -30,6 +30,7 @@ const { canAccessStream } = createVideoAuthPolicy({
|
||||
ptzCameraService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
io,
|
||||
});
|
||||
|
||||
registerVideoAuthRoute({
|
||||
|
||||
@@ -19,8 +19,31 @@ function createVideoAuthPolicy(deps) {
|
||||
ptzCameraService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
io,
|
||||
} = 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) {
|
||||
const mode = getMode();
|
||||
if (!socket) return false;
|
||||
@@ -78,11 +101,15 @@ function createVideoAuthPolicy(deps) {
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
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
|
||||
after a token has been issued, so the active-turn bandwidth rule must
|
||||
be evaluated here too instead of trusting an older browser decision.
|
||||
after a token has been issued, so the same "must belong to this rover's
|
||||
driver queue" rule has to be evaluated here too.
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,29 @@ function canViewRoomCamera(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 = {}) {
|
||||
if (!payload) return null;
|
||||
if (payload.type && payload.id) {
|
||||
@@ -129,16 +152,16 @@ io.on('connection', (socket) => {
|
||||
!isAudio &&
|
||||
role !== 'spectator' &&
|
||||
!isAdmin(socket) &&
|
||||
shouldUseSnapshotsForNonTurnVideo() &&
|
||||
!turnService.canDrive(baseId, socket)
|
||||
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
|
||||
!turnService.canRequestLiveVideo(baseId, socket)
|
||||
) {
|
||||
/*
|
||||
The browser also forces snapshots for non-active turn holders, but
|
||||
the socket token path must enforce the same rule. Otherwise a stale
|
||||
component or direct socket caller could still mint a MediaMTX token
|
||||
while the UI is showing snapshots.
|
||||
The browser owns the snapshot-vs-live presentation for queued rover
|
||||
drivers. The server side only verifies that the socket belongs to
|
||||
this rover's driver queue so legitimate warm-up/switch requests are
|
||||
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') {
|
||||
throw new Error('Room cameras now use the snapshot feed');
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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
|
||||
3. make overcurrent limiter speed sensitive, slower fill at lower speeds
|
||||
4. add more background gap themes
|
||||
5. fix this:
|
||||
3. add flag in roverd for video aspect ratio
|
||||
1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
|
||||
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]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
+15
-31
@@ -15,6 +15,7 @@ import {
|
||||
} from './controls/index.js';
|
||||
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from './components/KinectPanel/index.jsx';
|
||||
import BalanceBoardPanel from './components/BalanceBoardPanel/index.jsx';
|
||||
import DriverVideo from './components/DriverVideo/index.jsx';
|
||||
import RightPaneTabs from './components/RightPaneTabs/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 SocketConnectionPill from './components/SocketConnectionPill/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';
|
||||
|
||||
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;
|
||||
}
|
||||
import useLayoutMode from './hooks/useLayoutMode.js';
|
||||
|
||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||
return (
|
||||
@@ -200,6 +177,7 @@ function MobileFeatureTabs({
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<BalanceBoardPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<OdometerPanel />
|
||||
<ButtonBoxPanel />
|
||||
@@ -307,6 +285,12 @@ function App() {
|
||||
const layout = useLayoutMode();
|
||||
const isDesktop = layout === 'desktop';
|
||||
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 (
|
||||
<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 board’s 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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import useChatMessageHistoryNavigation from '../../hooks/useChatMessageHistoryNavigation.js';
|
||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
@@ -260,6 +261,7 @@ function ChatComposer({
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [speak, setSpeak] = useState(true);
|
||||
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||
const effectiveSpeak = ttsSupported && speak;
|
||||
const ttsPayload = useMemo(() => {
|
||||
if (!effectiveSpeak) return null;
|
||||
@@ -291,6 +293,7 @@ function ChatComposer({
|
||||
try {
|
||||
await sendMessage(clean, ttsPayload);
|
||||
setDraft('');
|
||||
resetHistoryNavigation();
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
} catch (err) {
|
||||
@@ -314,6 +317,9 @@ function ChatComposer({
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
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);
|
||||
setTypingActive(Boolean(next.trim()));
|
||||
}}
|
||||
@@ -326,6 +332,15 @@ function ChatComposer({
|
||||
setTypingActive(false);
|
||||
}}
|
||||
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()) {
|
||||
event.preventDefault();
|
||||
blurChat();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { memo, useMemo, useState } from 'react';
|
||||
import { useChatActions } from '../../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import useChatMessageHistoryNavigation from '../../../hooks/useChatMessageHistoryNavigation.js';
|
||||
|
||||
function detectSafari() {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
@@ -42,6 +43,7 @@ function HudChatInput({ compact = false }) {
|
||||
});
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||
const canChat = role !== 'spectator';
|
||||
const hideHudChat = role === 'spectator';
|
||||
const chatTargetId = useMemo(() => {
|
||||
@@ -118,6 +120,7 @@ function HudChatInput({ compact = false }) {
|
||||
try {
|
||||
await sendMessage(clean, ttsPayload);
|
||||
setDraft('');
|
||||
resetHistoryNavigation();
|
||||
blurChat();
|
||||
setTypingActive(false);
|
||||
} catch (err) {
|
||||
@@ -136,6 +139,9 @@ function HudChatInput({ compact = false }) {
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
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);
|
||||
setTypingActive(Boolean(next.trim()));
|
||||
}}
|
||||
@@ -148,6 +154,15 @@ function HudChatInput({ compact = false }) {
|
||||
setTypingActive(false);
|
||||
}}
|
||||
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()) {
|
||||
event.preventDefault();
|
||||
blurChat();
|
||||
|
||||
@@ -1,63 +1,74 @@
|
||||
// Overcurrent Overlay
|
||||
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
// Purpose: Shows server-authoritative motor limiting, stop, recovery, and administrator-bypass status.
|
||||
// Scope: Renders protection state only; it never calculates stress or changes motor commands.
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
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 { 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 effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
||||
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
||||
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents;
|
||||
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
|
||||
const overcurrentMotors = useMemo(
|
||||
() =>
|
||||
resolvedOvercurrents == null
|
||||
? []
|
||||
: Object.entries(resolvedOvercurrents)
|
||||
.filter(([, active]) => Boolean(active))
|
||||
.map(([key]) => key),
|
||||
[resolvedOvercurrents],
|
||||
const protection = overcurrentLimiter ?? internalLimiter;
|
||||
const status = protection?.status || 'idle';
|
||||
const motors = protection?.motors || {};
|
||||
const activeMotors = useMemo(
|
||||
() => Object.entries(motors)
|
||||
.filter(([, motor]) => Boolean(motor?.overcurrent) || Number(motor?.stress) > 0)
|
||||
.map(([key]) => key),
|
||||
[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;
|
||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
||||
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
||||
const safeFill = Math.max(0, Math.min(1, fill));
|
||||
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const stopReason = protection?.drive?.stopReason;
|
||||
const displayMotors = stopReason ? [stopReason] : activeMotors;
|
||||
const labels = displayMotors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
const highestStress = displayMotors.reduce(
|
||||
(highest, name) => Math.max(highest, Number(motors?.[name]?.stress) || 0),
|
||||
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 (
|
||||
<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="absolute inset-0 overflow-hidden">
|
||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||
</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>OVERCURRENT</div>
|
||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<div className={`absolute inset-y-0 left-0 ${fillClass}`} 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 className={titleClass}>{title}</div>
|
||||
<div className={`font-medium ${detailClass}`}>{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Inter Instance Panel
|
||||
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
||||
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
@@ -133,45 +133,52 @@ function RemoteMediaStrip({ remote }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalInstancesCompact() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
export function ExternalInstancesCompact({ onBrowse = null }) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
|
||||
if (!enabled) return null;
|
||||
if (!visible.length) return null;
|
||||
const browseAction = onBrowse ? (
|
||||
<button type="button" className="button-dark" onClick={onBrowse}>
|
||||
Browse Servers
|
||||
</button>
|
||||
) : null;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<button type="button" className="button-dark w-full" onClick={() => setExpanded((value) => !value)}>
|
||||
{expanded ? 'Hide external' : `Show external (${visible.length})`}
|
||||
</button>
|
||||
<button type="button" className="button-dark w-full" onClick={() => setPopupOpen(true)}>
|
||||
Browse servers
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="space-y-0.5">
|
||||
{visible.map((remote) =>
|
||||
remote.online ? (
|
||||
<RoverQueuesPanel
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
) : (
|
||||
<InstancePanel key={remote.url} remote={remote} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{popupOpen ? <InterInstancePopup onClose={() => setPopupOpen(false)} /> : null}
|
||||
</div>
|
||||
/*
|
||||
External instances are intentionally always mounted. Besides removing an
|
||||
unnecessary disclosure click, this preserves the live queue rows while
|
||||
the local Rover Queues card can provide one continuous scroll surface for
|
||||
both its local and external rows. Scrolling belongs to that owning panel,
|
||||
so this nested section deliberately keeps its natural content height.
|
||||
*/
|
||||
<CardFrame
|
||||
title="External servers below:"
|
||||
actions={browseAction}
|
||||
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) =>
|
||||
remote.online ? (
|
||||
<RoverQueuesPanel
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
) : (
|
||||
<InstancePanel key={remote.url} remote={remote} />
|
||||
),
|
||||
)}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<InterInstanceBrowserFrame
|
||||
onClose={onClose}
|
||||
className="max-w-[calc(100vw-0.5rem)]"
|
||||
bodyClassName="max-h-[82vh] overflow-y-auto p-0.5"
|
||||
scaledOverlay
|
||||
className="inter-instance-overlay-frame"
|
||||
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -226,6 +234,7 @@ export function InterInstanceBrowserFrame({
|
||||
className = '',
|
||||
bodyClassName = 'p-0.5',
|
||||
centered = false,
|
||||
scaledOverlay = false,
|
||||
}) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
@@ -245,7 +254,7 @@ export function InterInstanceBrowserFrame({
|
||||
<CardFrame
|
||||
title="External instances"
|
||||
actions={actions}
|
||||
className={className}
|
||||
className={classNames(scaledOverlay && 'inter-instance-overlay-scale', className)}
|
||||
bodyClassName={bodyClassName}
|
||||
clipOverflow={false}
|
||||
>
|
||||
|
||||
@@ -148,13 +148,25 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
|
||||
return () => {
|
||||
clearRepeatTimer();
|
||||
/*
|
||||
Mobile controls can unmount when layouts change or the driver leaves the
|
||||
control surface. Clear the shared flag so a stale mobile precision choice
|
||||
cannot leave desktop/keyboard camera tilt in fine-step mode.
|
||||
Mobile controls can unmount during an orientation/layout change while a
|
||||
pointer is still captured by the disappearing element. Publish a neutral
|
||||
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);
|
||||
};
|
||||
}, [clearRepeatTimer, setCameraPrecisionMode]);
|
||||
}, [clearRepeatTimer, registerInputState, setCameraPrecisionMode, setDriveVector]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
|
||||
@@ -102,8 +102,9 @@ export default function ModeGateOverlay() {
|
||||
*/
|
||||
<InterInstanceBrowserFrame
|
||||
hideWhenEmpty
|
||||
className="max-w-[calc(100vw-0.5rem)]"
|
||||
bodyClassName="max-h-[86vh] overflow-y-auto p-0.5"
|
||||
scaledOverlay
|
||||
className="inter-instance-overlay-frame"
|
||||
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Overcurrent Limiter Panel
|
||||
// Purpose: Defines the Overcurrent Limiter 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.
|
||||
import { useMemo } from 'react';
|
||||
// Overcurrent Protection Panel
|
||||
// Purpose: Presents detailed server-calculated motor stress and command-tracking diagnostics.
|
||||
// Scope: Read-only status surface for the assigned rover; protection and recovery remain server-owned.
|
||||
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { OVERCURRENT_GROUPS } from '../../controls/overcurrentLimiter.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
const GROUP_LABELS = {
|
||||
drive: 'Drive wheels',
|
||||
aux: 'Aux motors',
|
||||
const MOTOR_LABELS = {
|
||||
leftWheel: 'Left wheel',
|
||||
rightWheel: 'Right wheel',
|
||||
mainBrush: 'Main brush',
|
||||
sideBrush: 'Side brush',
|
||||
};
|
||||
|
||||
function formatPct(value) {
|
||||
@@ -16,8 +17,20 @@ function formatPct(value) {
|
||||
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' }) {
|
||||
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 (
|
||||
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
||||
<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() {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const overcurrentLimiter = useControlSelector((control) => control.overcurrentLimiter);
|
||||
const groups = useMemo(() => OVERCURRENT_GROUPS.map((group) => group.key), []);
|
||||
const protection = useControlSelector((control) => control.overcurrentLimiter);
|
||||
const motors = protection?.motors || {};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Overcurrent limiter"
|
||||
meta={overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
title="Overcurrent protection"
|
||||
meta={statusLabel(protection)}
|
||||
bodyClassName="space-y-1 text-sm"
|
||||
>
|
||||
{!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">
|
||||
{groups.map((key) => {
|
||||
const cap = overcurrentLimiter?.caps?.[key]?.cap ?? 0;
|
||||
const over = overcurrentLimiter?.overcurrent?.groups?.[key] ?? false;
|
||||
const scale = overcurrentLimiter?.scales?.perGroup?.[key] ?? 1;
|
||||
<div className="space-y-1">
|
||||
{Object.entries(MOTOR_LABELS).map(([key, label]) => {
|
||||
const motor = motors[key] || {};
|
||||
const wheel = key === 'leftWheel' || key === 'rightWheel';
|
||||
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">
|
||||
<span className="text-slate-200">{GROUP_LABELS[key] || key}</span>
|
||||
<span className={over ? 'text-red-300' : 'text-slate-400'}>
|
||||
{over ? 'overcurrent' : 'ok'}
|
||||
<span className="text-slate-200">{label}</span>
|
||||
<span className={motor.overcurrent ? 'text-red-300' : 'text-slate-400'}>
|
||||
{motor.overcurrent ? 'Overcurrent' : 'Clear'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Cap</span>
|
||||
<span>{formatPct(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 className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||
<span>Stress {formatPct(motor.stress)}</span>
|
||||
<span>Output {formatPct(motor.cap)}</span>
|
||||
</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>
|
||||
) : null}
|
||||
</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>{`Down rate ${overcurrentLimiter?.config?.downRatePerSec}/s · Up rate ${overcurrentLimiter?.config?.upRatePerSec}/s`}</div>
|
||||
<div>{`Release delay ${overcurrentLimiter?.config?.releaseDelaySec}s`}</div>
|
||||
<div>{`Output rate ${overcurrentLimiter?.config?.outputRateMs}ms`}</div>
|
||||
<div>{`Drive output ${formatPct(protection?.drive?.cap)}`}</div>
|
||||
<div>
|
||||
{protection?.adminImmune
|
||||
? 'This session bypasses all overcurrent enforcement.'
|
||||
: 'Status and output limits are calculated by the server.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,8 @@ const PLACEHOLDER_STATS = Object.freeze({
|
||||
qualityMax: 70,
|
||||
rxBitrateMbit: 72.2,
|
||||
txBitrateMbit: 58.5,
|
||||
downloadMbps: 12.4,
|
||||
uploadMbps: 3.7,
|
||||
rxBytes: 12400000,
|
||||
txBytes: 2300000,
|
||||
rxPackets: 12640,
|
||||
@@ -304,14 +306,22 @@ export default function PiHostStatsCard() {
|
||||
|
||||
<section className="min-w-0 space-y-0.5">
|
||||
<ColumnTitle label="WiFi" />
|
||||
<div className="surface grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div>
|
||||
<div className="text-xs text-slate-400">{formatFrequency(wifi.frequencyMhz)}</div>
|
||||
<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="min-w-0">
|
||||
<div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div>
|
||||
<div className="text-xs text-slate-400">{formatFrequency(wifi.frequencyMhz)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-semibold leading-tight ${toneTextClass(currentSignalTone)}`}>{formatDbm(wifi.signalDbm)}</div>
|
||||
<SignalBars bars={bars} tone={currentSignalTone} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-semibold leading-tight ${toneTextClass(currentSignalTone)}`}>{formatDbm(wifi.signalDbm)}</div>
|
||||
<SignalBars bars={bars} tone={currentSignalTone} />
|
||||
<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
|
||||
@@ -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' }) {
|
||||
return (
|
||||
<div className="surface">
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// PTZ Camera UI
|
||||
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a
|
||||
// queueable controllable target instead of a VIP-panel card.
|
||||
// Scope: Owns PTZ entry card and fullscreen composition; PTZ command authority,
|
||||
// queue ownership, and stream authorization remain server-owned.
|
||||
// Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
|
||||
// composition; PTZ command authority, queue ownership, and stream authorization
|
||||
// remain server-owned.
|
||||
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 ChatPanel from '../ChatPanel/index.jsx';
|
||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/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 { isFeatureEnabled } from '../../lib/features.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';
|
||||
|
||||
@@ -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' }) {
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
@@ -223,41 +188,30 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { nudgeServo, stopAllMotion } = useControlActions();
|
||||
const repeatTimerRef = useRef(null);
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
|
||||
const stopZoom = useCallback(() => {
|
||||
/*
|
||||
Mobile zoom is intentionally routed through the normal camera-up/down
|
||||
control action instead of emitting PTZ socket commands directly. That
|
||||
keeps the zoom buttons on the same path as keyboard/gamepad camera tilt,
|
||||
and the PTZ adapter remains the one place that translates "camera nudge"
|
||||
into Reolink zoom pulses.
|
||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
||||
cannot erase the other finger's intent.
|
||||
*/
|
||||
if (repeatTimerRef.current) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
stopAllMotion();
|
||||
}, [stopAllMotion]);
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Send an immediate nudge and then repeat while held. The adapter turns
|
||||
each nudge into a short zoom pulse, so repeating the standard action is
|
||||
the simplest way to get continuous hold-to-zoom without adding another
|
||||
PTZ-specific command loop.
|
||||
Publish held state once. The adapter owns the single motion heartbeat,
|
||||
so this button no longer creates a second interval whose queued callback
|
||||
could run after pointerup and restart zoom.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
nudgeServo(direction);
|
||||
repeatTimerRef.current = setInterval(() => {
|
||||
nudgeServo(direction);
|
||||
}, 120);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, nudgeServo, stopZoom],
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
@@ -272,16 +226,12 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
() => () => {
|
||||
/*
|
||||
A touch surface can unmount during orientation changes or fullscreen
|
||||
close while a pointer is still down. Clear the repeat timer here so a
|
||||
held zoom button cannot keep firing camera-up/down actions after the
|
||||
mobile controls have disappeared.
|
||||
close while a pointer is still down. Explicitly clear zoom here because
|
||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
||||
*/
|
||||
if (repeatTimerRef.current) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[],
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -293,7 +243,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
@@ -305,7 +255,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
@@ -540,8 +490,9 @@ function buildPtzTurnModel(ptz, selfId) {
|
||||
|
||||
function PtzMediaPane({ ptz, open, framed = true }) {
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const nonTurnVideoPolicy = useSessionSelector(
|
||||
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots',
|
||||
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
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
|
||||
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 snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
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' }}>
|
||||
<PtzMediaPane ptz={ptz} open framed />
|
||||
</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} />
|
||||
{ptz?.isOperator ? (
|
||||
<PtzLightingControls ptz={ptz} />
|
||||
@@ -595,8 +557,13 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
</CardFrame>
|
||||
)}
|
||||
<PtzControlReference />
|
||||
<PtzStatePanel ptz={ptz} />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
||||
{/*
|
||||
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>
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
||||
@@ -612,78 +579,258 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileFullscreen({ ptz, layout, 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]';
|
||||
|
||||
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<aside className="min-h-0 overflow-y-auto">
|
||||
{/*
|
||||
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
|
||||
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>
|
||||
{/*
|
||||
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} />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-landscape"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<div className="space-y-0.5">
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
<PtzStatePanel ptz={ptz} compact />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
|
||||
</div>
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</section>
|
||||
</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 { 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 navigate = useNavigate();
|
||||
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 () => {
|
||||
if (releasePending) return;
|
||||
setReleasePending(true);
|
||||
closingThroughButtonRef.current = true;
|
||||
try {
|
||||
/*
|
||||
Stop first so a held key/pointer cannot leave ONVIF continuous movement
|
||||
running while the server removes this socket from the PTZ queue.
|
||||
*/
|
||||
stopAllMotion?.();
|
||||
await ptzRelease();
|
||||
onClose?.();
|
||||
if (ptz?.isOperator || ptz?.queuedPosition) {
|
||||
await ptzRelease();
|
||||
}
|
||||
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 {
|
||||
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 = (
|
||||
/*
|
||||
The PTZ controller needs to cover the driver page, but it must not become
|
||||
the top-most application layer. Global fullscreen overlays like help,
|
||||
quickstart, mode gates, and connection warnings are still part of the
|
||||
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">
|
||||
return (
|
||||
<main className={`h-[100dvh] w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
||||
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
|
||||
transparent body is what lets every desktop and mobile PTZ composition share one
|
||||
continuous pattern without threading theme props into each individual child panel. */}
|
||||
<CardFrame
|
||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
||||
actions={isMobile ? null : (
|
||||
@@ -695,23 +842,20 @@ export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
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 ? (
|
||||
<PtzMobileFullscreen
|
||||
ptz={ptz}
|
||||
layout={layout}
|
||||
onClose={releaseAndClose}
|
||||
releasePending={releasePending}
|
||||
/>
|
||||
layout === 'mobile-landscape' ? (
|
||||
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
) : (
|
||||
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
)
|
||||
) : (
|
||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
||||
)}
|
||||
</CardFrame>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
return createPortal(controller, document.body);
|
||||
}
|
||||
|
||||
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 selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
||||
const [controllerOpen, setControllerOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
||||
@@ -735,7 +879,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
const handleRequest = async () => {
|
||||
if (!canUse || pending) return;
|
||||
if (isParticipant) {
|
||||
setControllerOpen(true);
|
||||
navigate('/ptz');
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
@@ -748,7 +892,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
dock-guard rejection does not strand the user in fullscreen.
|
||||
*/
|
||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
||||
setControllerOpen(true);
|
||||
navigate('/ptz');
|
||||
}
|
||||
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
|
||||
} catch (err) {
|
||||
@@ -784,8 +928,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
: 'request';
|
||||
|
||||
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">
|
||||
<QueueTargetRow
|
||||
target={{
|
||||
@@ -823,8 +966,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
Verify your account to use the PTZ camera.
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
<PtzFullscreenController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
|
||||
</>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,13 +35,16 @@ function selectedKeysEqual(left, right) {
|
||||
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 mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { triggerReplay } = useSessionActions();
|
||||
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||
@@ -65,20 +68,35 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const activeReplayJob = useSessionSelector((state) => (
|
||||
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(
|
||||
latestReplay?.url &&
|
||||
latestReplayJobId &&
|
||||
dismissedPanelReplayId !== latestReplayJobId,
|
||||
panelReplay?.url &&
|
||||
panelReplayJobId &&
|
||||
dismissedPanelReplayId !== panelReplayJobId,
|
||||
);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
const roverId = assignmentRoverId;
|
||||
if (roverId) {
|
||||
return [`rover:${roverId}`];
|
||||
const availableDefaultKey = useMemo(() => {
|
||||
// PTZ layouts provide their camera key explicitly so entering the dedicated
|
||||
// camera page does not inherit the user's assigned rover. Waiting until the
|
||||
// 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 [];
|
||||
}, [assignmentRoverId]);
|
||||
const roverKey = assignmentRoverId ? `rover:${assignmentRoverId}` : null;
|
||||
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 roverId = assignmentRoverId || null;
|
||||
@@ -225,9 +243,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
{showPanelReplay ? (
|
||||
<div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2">
|
||||
<ReplayReadyPopup
|
||||
replay={latestReplay}
|
||||
replay={panelReplay}
|
||||
variant="floating-panel"
|
||||
onClose={() => setDismissedPanelReplayId(latestReplayJobId)}
|
||||
onClose={() => setDismissedPanelReplayId(panelReplayJobId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -261,6 +279,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
setTitleDirty(true);
|
||||
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}
|
||||
maxLength={120}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomCameraPanel from '../RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from '../KinectPanel/index.jsx';
|
||||
import BalanceBoardPanel from '../BalanceBoardPanel/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import SettingsPanel from '../SettingsPanel/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 { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
|
||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||
@@ -195,9 +196,18 @@ function QueueReplayLinksRow() {
|
||||
removes that item instead of preserving an empty grid column.
|
||||
*/
|
||||
return (
|
||||
<div className={`flex ${themeGapClass}`}>
|
||||
<div className={`min-w-0 basis-0 grow-[1] space-y-0.5`}>
|
||||
<RoverQueuesPanel />
|
||||
<div className={`flex items-stretch ${themeGapClass}`}>
|
||||
<div className="relative min-w-0 basis-0 grow-[1]">
|
||||
{/*
|
||||
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 className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
@@ -420,6 +430,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<div className={`flex flex-col ${themeGapClass}`}>
|
||||
<NeatoCard />
|
||||
<LiftCard />
|
||||
<BalanceBoardPanel />
|
||||
<BarcodeGamesPanel />
|
||||
<OdometerPanel />
|
||||
<ButtonBoxPanel />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Rover Queues Panel
|
||||
// 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.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.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 { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
@@ -25,6 +25,74 @@ function batteryClass(rover) {
|
||||
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({
|
||||
title = 'Rovers',
|
||||
roster: rosterOverride = null,
|
||||
@@ -32,6 +100,7 @@ export default function RoverQueuesPanel({
|
||||
users: usersOverride = null,
|
||||
externalInstance = null,
|
||||
disabledOverlay = '',
|
||||
fillHeight = false,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
@@ -50,6 +119,7 @@ export default function RoverQueuesPanel({
|
||||
const { requestControl, rebootOwnRover } = useSessionActions();
|
||||
const [pending, setPending] = useState({});
|
||||
const [rebootPending, setRebootPending] = useState(false);
|
||||
const [interInstancePopupOpen, setInterInstancePopupOpen] = useState(false);
|
||||
const externalMode = Boolean(externalInstance);
|
||||
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
||||
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||
@@ -146,8 +216,8 @@ export default function RoverQueuesPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const headerActions =
|
||||
!externalMode && role !== 'spectator' && assignedRoverId ? (
|
||||
const rebootAction =
|
||||
role !== 'spectator' && assignedRoverId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRebootOwnRover}
|
||||
@@ -159,77 +229,98 @@ export default function RoverQueuesPanel({
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const headerActions = !externalMode ? rebootAction : null;
|
||||
|
||||
return (
|
||||
<CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm">
|
||||
<div className="relative space-y-0.5">
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remainingSeconds =
|
||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: lockedBlocked
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
key={rover.id}
|
||||
target={{ ...rover, rover, roverId, id: roverId }}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canClickRow}
|
||||
pending={Boolean(pending[roverId])}
|
||||
locked={locked}
|
||||
lockedBlocked={lockedBlocked}
|
||||
privateOpen={isPrivateOpen}
|
||||
buttonLabel={buttonLabel}
|
||||
batteryLabel={formatBattery(rover)}
|
||||
batteryClassName={batteryClass(rover)}
|
||||
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
||||
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
||||
onRequest={handleRequest}
|
||||
showAction={Boolean(canRequest)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{externalBlocked ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
{disabledOverlay}
|
||||
<>
|
||||
<CardFrame
|
||||
title={title}
|
||||
actions={headerActions}
|
||||
fillHeight={fillHeight}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
>
|
||||
<ScrollableQueueContent enabled={fillHeight}>
|
||||
<div className="relative space-y-0.5">
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remainingSeconds =
|
||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: lockedBlocked
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
key={rover.id}
|
||||
target={{ ...rover, rover, roverId, id: roverId }}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canClickRow}
|
||||
pending={Boolean(pending[roverId])}
|
||||
locked={locked}
|
||||
lockedBlocked={lockedBlocked}
|
||||
privateOpen={isPrivateOpen}
|
||||
buttonLabel={buttonLabel}
|
||||
batteryLabel={formatBattery(rover)}
|
||||
batteryClassName={batteryClass(rover)}
|
||||
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
||||
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
||||
onRequest={handleRequest}
|
||||
showAction={Boolean(canRequest)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{externalBlocked ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
{disabledOverlay}
|
||||
</div>
|
||||
) : null}
|
||||
{!externalMode && interInstanceEnabled ? (
|
||||
<ExternalInstancesCompact onBrowse={() => setInterInstancePopupOpen(true)} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null}
|
||||
</div>
|
||||
</CardFrame>
|
||||
</ScrollableQueueContent>
|
||||
</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Settings Panel
|
||||
// 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.
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import AuthPanel from '../AuthPanel/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 { formatKeyLabel } from '../../controls/keymapUtils.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 = [
|
||||
{ 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>;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
// 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.
|
||||
@@ -116,6 +141,7 @@ export default function SettingsPanel() {
|
||||
swapMobileControlColumns: false,
|
||||
driveMacroBackoffEnabled: true,
|
||||
interInstanceTransferSettings: true,
|
||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||
});
|
||||
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
|
||||
@@ -126,6 +152,11 @@ export default function SettingsPanel() {
|
||||
? pageSettings.driveMacroBackoffEnabled
|
||||
: true;
|
||||
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 alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
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 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(
|
||||
() => [
|
||||
{ key: 'start', label: 'Enable stream', enable: true },
|
||||
@@ -202,6 +240,32 @@ export default function SettingsPanel() {
|
||||
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 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
|
||||
for comfortable pointer control. */}
|
||||
<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">
|
||||
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<input
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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.
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vip PTZ Camera Card
|
||||
// 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.
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ChatPanel from '../ChatPanel/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 KeyPill from './VipAudioUploadCard/KeyPill.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 { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
const PTZ_ZOOM_SPEED = 0.55;
|
||||
|
||||
function formatRemaining(deadline) {
|
||||
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 }) {
|
||||
const { ptzMove, ptzStop } = useSessionActions();
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
const stopZoom = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
// This is a zoom-only release; the shared adapter retains any simultaneous
|
||||
// pan/tilt intent from the movement pad in its next combined motion state.
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Mobile needs explicit zoom targets because the regular mobile drive pad
|
||||
is already used for pan/tilt. Desktop does not render these buttons; it
|
||||
uses the mapped camera up/down controls shown in the reference panel.
|
||||
The adapter owns renewal for held PTZ state. Publishing the direction
|
||||
once avoids a component-local repeat timer and keeps this legacy card on
|
||||
the exact same motion path as the dedicated PTZ route.
|
||||
*/
|
||||
event.preventDefault();
|
||||
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(
|
||||
(event) => {
|
||||
@@ -244,6 +246,15 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
[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 (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<button
|
||||
@@ -253,7 +264,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
@@ -265,7 +276,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onPointerLeave={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
@@ -326,8 +337,8 @@ function PtzControlReference() {
|
||||
function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const nonTurnVideoPolicy = useSessionSelector(
|
||||
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots',
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
||||
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;
|
||||
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 snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
@@ -384,7 +395,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzStatePanel
|
||||
@@ -410,7 +421,10 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzStatePanel
|
||||
|
||||
@@ -30,6 +30,12 @@ const CHAT_FOCUS_DEFAULT = {
|
||||
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
|
||||
// them in their own context lets transcript components update without forcing
|
||||
// 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.
|
||||
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({
|
||||
...CHAT_TIMELINE_DEFAULT,
|
||||
...CHAT_ACTIONS_DEFAULT,
|
||||
@@ -56,6 +68,7 @@ export function ChatProvider({ children }) {
|
||||
const { pushAlert } = useSessionActions();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||
const { value: chatSettings, save: saveChatSettings } = useSettingsNamespace('chat', CHAT_HISTORY_DEFAULT);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
@@ -229,11 +242,29 @@ export function ChatProvider({ children }) {
|
||||
hasTts: Boolean(tts),
|
||||
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);
|
||||
}
|
||||
});
|
||||
}),
|
||||
[profileImage, socket],
|
||||
[profileImage, saveChatSettings, socket],
|
||||
);
|
||||
|
||||
const registerInputRef = useCallback((el, options = {}) => {
|
||||
@@ -301,13 +332,23 @@ export function ChatProvider({ children }) {
|
||||
[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(
|
||||
() => ({
|
||||
...timelineValue,
|
||||
...actionsValue,
|
||||
...focusValue,
|
||||
...historyValue,
|
||||
}),
|
||||
[actionsValue, focusValue, timelineValue],
|
||||
[actionsValue, focusValue, historyValue, timelineValue],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -316,7 +357,9 @@ export function ChatProvider({ children }) {
|
||||
<ChatTimelineContext.Provider value={timelineValue}>
|
||||
<ChatActionsContext.Provider value={actionsValue}>
|
||||
<ChatFocusContext.Provider value={focusValue}>
|
||||
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||
<ChatHistoryContext.Provider value={historyValue}>
|
||||
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||
</ChatHistoryContext.Provider>
|
||||
</ChatFocusContext.Provider>
|
||||
</ChatActionsContext.Provider>
|
||||
</ChatTimelineContext.Provider>
|
||||
@@ -354,3 +397,11 @@ export function useChatFocus() {
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useChatHistory() {
|
||||
const ctx = useContext(ChatHistoryContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useChatHistory must be used inside ChatProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -426,8 +426,6 @@ export function SessionProvider({ children }) {
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||
ptzRelease: () => emitWithAck('ptzCamera:release'),
|
||||
ptzMove: (payload = {}) => emitWithAck('ptzCamera:move', payload),
|
||||
ptzStop: () => emitWithAck('ptzCamera:stop'),
|
||||
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
||||
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
|
||||
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
|
||||
|
||||
@@ -231,7 +231,7 @@ export function TelemetryProvider({ children }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {}, overcurrentProtection = null }) {
|
||||
if (!roverId) return;
|
||||
const previous = framesRef.current[roverId] ?? {};
|
||||
framesRef.current = {
|
||||
@@ -240,6 +240,10 @@ export function TelemetryProvider({ children }) {
|
||||
...previous,
|
||||
roverId,
|
||||
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,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
|
||||
@@ -30,11 +30,7 @@ import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
import {
|
||||
applyAuxOvercurrentScale,
|
||||
applyDriveOvercurrentScale,
|
||||
useOvercurrentLimiter,
|
||||
} from './overcurrentLimiter.js';
|
||||
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
@@ -49,6 +45,7 @@ const CONTROL_ACTION_NAMES = [
|
||||
'setAuxMotors',
|
||||
'setServoAngle',
|
||||
'nudgeServo',
|
||||
'setCameraAxisIntent',
|
||||
'goServoHome',
|
||||
'setCameraPrecisionMode',
|
||||
'runMacro',
|
||||
@@ -176,15 +173,13 @@ export function ControlSystemProvider({ children }) {
|
||||
);
|
||||
const { homeAssistantSetState } = useSessionActions();
|
||||
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
||||
const driveTransform = useCallback(
|
||||
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
|
||||
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
||||
);
|
||||
const auxTransform = useCallback(
|
||||
(values) => applyAuxOvercurrentScale(values, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
|
||||
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
||||
);
|
||||
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
|
||||
/*
|
||||
Motor commands now remain raw until they reach the server-owned protection
|
||||
service. Applying another transform here would make non-admin commands pass
|
||||
through two independent limiters and would let browser lifecycle determine
|
||||
whether protection exists at all.
|
||||
*/
|
||||
const pipeline = useCommandPipeline();
|
||||
const ptzControls = usePtzControlAdapter();
|
||||
|
||||
const turnOnAllLights = useCallback(() => {
|
||||
@@ -288,39 +283,6 @@ export function ControlSystemProvider({ children }) {
|
||||
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(
|
||||
(vector, meta = {}) => {
|
||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||
@@ -391,21 +353,12 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value, options = {}) => {
|
||||
if (ptzControls.isActive) {
|
||||
/*
|
||||
Servo-capable rover controls converge here from keyboard, gamepad,
|
||||
desktop, and mobile. When the active control target is the PTZ camera,
|
||||
route the intent through the PTZ adapter instead of making the rover
|
||||
command pipeline understand camera zoom semantics.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
/*
|
||||
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
||||
enters through setCameraAxisIntent as a signed held velocity, so this
|
||||
function must not infer zoom direction by comparing unrelated absolute
|
||||
angle values from gamepad/manual-dock callers.
|
||||
*/
|
||||
if (!pipeline.servoConfig) return;
|
||||
const force = Boolean(options?.force);
|
||||
if (state.manualDockAssist?.active && !force) return;
|
||||
@@ -415,13 +368,24 @@ export function ControlSystemProvider({ children }) {
|
||||
servoAngleRef.current = clamped;
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
||||
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||
);
|
||||
|
||||
const nudgeServo = useCallback(
|
||||
(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;
|
||||
if (!config && !ptzControls.isActive) return;
|
||||
if (!config) return;
|
||||
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
|
||||
const baseline =
|
||||
typeof servoAngleRef.current === 'number'
|
||||
@@ -431,7 +395,28 @@ export function ControlSystemProvider({ children }) {
|
||||
: 0;
|
||||
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(() => {
|
||||
@@ -730,6 +715,7 @@ export function ControlSystemProvider({ children }) {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
goServoHome,
|
||||
setCameraPrecisionMode,
|
||||
runMacro,
|
||||
@@ -757,6 +743,7 @@ export function ControlSystemProvider({ children }) {
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
goServoHome,
|
||||
setCameraPrecisionMode,
|
||||
runMacro,
|
||||
|
||||
@@ -53,6 +53,7 @@ export default function GamepadInputManager() {
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
setServoAngle,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
toggleHeadlight,
|
||||
toggleLaser,
|
||||
@@ -173,6 +174,7 @@ export default function GamepadInputManager() {
|
||||
runMacro,
|
||||
saveGamepadSettings,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setDriveVector,
|
||||
setMode,
|
||||
setServoAngle,
|
||||
@@ -187,6 +189,9 @@ export default function GamepadInputManager() {
|
||||
if (!latest) return;
|
||||
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
||||
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)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
@@ -204,6 +209,9 @@ export default function GamepadInputManager() {
|
||||
}
|
||||
|
||||
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)) {
|
||||
lastVectorRef.current = ZERO_VECTOR;
|
||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||
@@ -302,7 +310,14 @@ export default function GamepadInputManager() {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export default function KeyboardInputManager() {
|
||||
setDriveVector,
|
||||
setAuxMotors,
|
||||
nudgeServo,
|
||||
setCameraAxisIntent,
|
||||
runMacro,
|
||||
stopAllMotion,
|
||||
registerInputState,
|
||||
@@ -215,6 +216,13 @@ export default function KeyboardInputManager() {
|
||||
const ensureServoLoop = useCallback(() => {
|
||||
const direction = computeServoDirection();
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -235,7 +243,9 @@ export default function KeyboardInputManager() {
|
||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||
const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap);
|
||||
const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep;
|
||||
latest.nudgeServo(nextDirection * servoStep);
|
||||
if (!latest.setCameraAxisIntent(nextDirection)) {
|
||||
latest.nudgeServo(nextDirection * servoStep);
|
||||
}
|
||||
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
|
||||
};
|
||||
servoIntervalRef.current = setTimeout(tick, 0);
|
||||
@@ -394,6 +404,7 @@ export default function KeyboardInputManager() {
|
||||
servoRepeatMs,
|
||||
servoStep,
|
||||
setAuxMotors,
|
||||
setCameraAxisIntent,
|
||||
setCameraPrecisionMode,
|
||||
setDriveVector,
|
||||
setMicPttActive,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Overcurrent Limiter Hook/Utility
|
||||
// Purpose: Applies client-side overcurrent guard logic to reduce harmful command spikes. Scope: Tracks limiter state and exposes gated dispatch behavior to controls.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
// Overcurrent Protection View Hook
|
||||
// Purpose: Adapts server-authoritative protection telemetry for existing control and HUD consumers.
|
||||
// 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 { overcurrentFlagsEqual, selectOvercurrentFlags } from '../context/telemetryViews.js';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export const OVERCURRENT_GROUPS = [
|
||||
@@ -10,189 +11,63 @@ export const OVERCURRENT_GROUPS = [
|
||||
{ key: 'aux', motors: ['mainBrush', 'sideBrush'] },
|
||||
];
|
||||
|
||||
export const DEFAULT_OVERCURRENT_LIMITS = {
|
||||
downRatePerSec: 0.4,
|
||||
upRatePerSec: 0.5,
|
||||
releaseDelaySec: 2.5,
|
||||
outputRateMs: 250,
|
||||
};
|
||||
const EMPTY_PROTECTION = Object.freeze({
|
||||
status: 'idle',
|
||||
bypassed: false,
|
||||
drive: Object.freeze({ cap: 1, blocked: false, requiresNeutral: false, stopReason: null }),
|
||||
motors: Object.freeze({}),
|
||||
config: Object.freeze({}),
|
||||
});
|
||||
|
||||
const RECOVERED_CAP_THRESHOLD = 0.999;
|
||||
|
||||
function createInitialCaps() {
|
||||
return OVERCURRENT_GROUPS.reduce((acc, group) => {
|
||||
acc[group.key] = { cap: 1, clearSec: 0 };
|
||||
return acc;
|
||||
}, {});
|
||||
function selectOvercurrentProtection(frame) {
|
||||
return frame?.overcurrentProtection || EMPTY_PROTECTION;
|
||||
}
|
||||
|
||||
function clampUnit(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function useOvercurrentLimiter(roverId, options = {}) {
|
||||
export function useOvercurrentLimiter(roverId) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const overcurrentFlags = useTelemetrySelector(roverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
||||
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 protection = useTelemetrySelector(roverId, selectOvercurrentProtection);
|
||||
const adminImmune = role === 'admin' || role === 'lockdown';
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
caps,
|
||||
overcurrent,
|
||||
scales,
|
||||
/*
|
||||
HUD and resend behavior should only remain active while the limiter has
|
||||
meaningful scale left to recover. Using the same threshold as the tick
|
||||
loop prevents an empty overcurrent overlay from staying mounted after
|
||||
recovery has already stopped.
|
||||
*/
|
||||
isActive:
|
||||
(scales?.drive?.left ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
||||
(scales?.drive?.right ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
||||
(scales?.aux?.main ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
||||
(scales?.aux?.side ?? 1) < RECOVERED_CAP_THRESHOLD,
|
||||
config,
|
||||
return useMemo(() => {
|
||||
const motors = protection?.motors || {};
|
||||
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
|
||||
const mainCap = Number.isFinite(motors?.mainBrush?.cap) ? motors.mainBrush.cap : 1;
|
||||
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;
|
||||
}, {});
|
||||
|
||||
/*
|
||||
The compatibility-shaped fields keep existing control-context consumers
|
||||
simple while every value now comes from the same server snapshot. There
|
||||
is deliberately no local recovery loop: a stale or disconnected browser
|
||||
must never invent a safer state than the server actually calculated.
|
||||
*/
|
||||
return {
|
||||
...protection,
|
||||
caps: {
|
||||
drive: { cap: driveCap },
|
||||
aux: { cap: auxCap },
|
||||
},
|
||||
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,
|
||||
}),
|
||||
[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]);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// mobile, desktop, and gamepad inputs do not each learn camera-specific rules.
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
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_SPEEDS = {
|
||||
@@ -12,7 +12,11 @@ const PTZ_SPEEDS = {
|
||||
medium: 0.5,
|
||||
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) {
|
||||
const number = Number(value) || 0;
|
||||
@@ -98,10 +102,14 @@ function nextIrMode(currentMode) {
|
||||
|
||||
export function usePtzControlAdapter() {
|
||||
const socket = useSocket();
|
||||
const { ptzSpotlight, ptzIr } = useSessionActions();
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
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(
|
||||
(eventName, payload = {}) => {
|
||||
@@ -111,18 +119,13 @@ export function usePtzControlAdapter() {
|
||||
[isActive, socket],
|
||||
);
|
||||
|
||||
const stopMotion = useCallback(() => {
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
const stopSignature = payloadSignature(PTZ_STOP);
|
||||
if (lastMotionSignatureRef.current === stopSignature) return;
|
||||
lastMotionSignatureRef.current = stopSignature;
|
||||
emitPtz('ptzCamera:stop');
|
||||
}, [emitPtz]);
|
||||
const clearHeartbeat = useCallback(() => {
|
||||
if (!heartbeatTimerRef.current) return;
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const sendMotion = useCallback(
|
||||
const publishMotion = useCallback(
|
||||
(payload, options = {}) => {
|
||||
if (!isActive) return false;
|
||||
const nextPayload = {
|
||||
@@ -131,65 +134,111 @@ export function usePtzControlAdapter() {
|
||||
zoom: clampUnit(payload?.zoom),
|
||||
};
|
||||
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;
|
||||
lastMotionSignatureRef.current = nextSignature;
|
||||
if (isIdlePayload(nextPayload)) {
|
||||
emitPtz('ptzCamera:stop');
|
||||
} else {
|
||||
emitPtz('ptzCamera:move', nextPayload);
|
||||
}
|
||||
// Zero is a first-class desired state. The server translates the complete
|
||||
// idle vector into ONVIF Stop inside the same serialized command stream as
|
||||
// movement, which prevents separate move/stop handlers from racing.
|
||||
emitPtz('ptzCamera:motion', nextPayload);
|
||||
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(
|
||||
(vector, meta = {}) => {
|
||||
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;
|
||||
},
|
||||
[isActive, sendMotion],
|
||||
[isActive, publishMotion],
|
||||
);
|
||||
|
||||
const pulseZoom = useCallback(
|
||||
const setZoomIntent = useCallback(
|
||||
(direction) => {
|
||||
if (!isActive) return false;
|
||||
const sign = axisSign(direction);
|
||||
if (!sign) {
|
||||
stopMotion();
|
||||
return true;
|
||||
}
|
||||
const numeric = clampUnit(direction);
|
||||
const sign = axisSign(numeric);
|
||||
/*
|
||||
Zoom is different from pan/tilt because it is driven by repeated nudge
|
||||
events from existing camera controls. Force each pulse through even when
|
||||
the payload is identical, otherwise holding "camera up" only sends the
|
||||
first zoom command and every later nudge is de-duped away.
|
||||
PTZ zoom is a held velocity, not a rover servo nudge. Convert the input
|
||||
magnitude to the same precision/normal tiers used for pan and tilt, then
|
||||
retain it until the input surface explicitly publishes zero. The shared
|
||||
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 });
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
/*
|
||||
Existing rover camera controls are nudge/slider based, not hold-based.
|
||||
Treat each nudge as a short PTZ zoom pulse, then stop from the adapter
|
||||
so delayed stop behavior is owned by the camera layer only.
|
||||
*/
|
||||
zoomStopTimerRef.current = setTimeout(() => {
|
||||
zoomStopTimerRef.current = null;
|
||||
stopMotion();
|
||||
}, ZOOM_PULSE_MS);
|
||||
const speed = !sign ? 0 : Math.abs(numeric) <= 0.45 ? PTZ_SPEEDS.slow : PTZ_SPEEDS.medium;
|
||||
zoomIntentRef.current = sign * speed;
|
||||
publishMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion, stopMotion],
|
||||
[isActive, publishMotion],
|
||||
);
|
||||
|
||||
const setSpotlight = useCallback(
|
||||
(nextOn) => {
|
||||
if (!isActive) return false;
|
||||
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;
|
||||
},
|
||||
[emitPtz, isActive, ptz?.light],
|
||||
[isActive, ptz?.light, ptzSpotlight],
|
||||
);
|
||||
|
||||
const setIr = useCallback(
|
||||
@@ -198,27 +247,53 @@ export function usePtzControlAdapter() {
|
||||
const desiredState = typeof nextOn === 'boolean'
|
||||
? (nextOn ? 'On' : 'Off')
|
||||
: 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;
|
||||
},
|
||||
[emitPtz, isActive, ptz?.ir?.state],
|
||||
[isActive, ptz?.ir?.state, ptzIr],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) return undefined;
|
||||
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
desiredMotionRef.current = PTZ_STOP;
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
clearHeartbeat();
|
||||
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(
|
||||
() => () => {
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
clearHeartbeat();
|
||||
},
|
||||
[],
|
||||
[clearHeartbeat],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -226,11 +301,11 @@ export function usePtzControlAdapter() {
|
||||
isActive,
|
||||
state: ptz,
|
||||
applyDriveVector,
|
||||
pulseZoom,
|
||||
setZoomIntent,
|
||||
setSpotlight,
|
||||
setIr,
|
||||
stopMotion,
|
||||
}),
|
||||
[applyDriveVector, isActive, ptz, pulseZoom, setIr, setSpotlight, stopMotion],
|
||||
[applyDriveVector, isActive, ptz, setIr, setSpotlight, setZoomIntent, stopMotion],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import CardFrame from '../components/CardFrame/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
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';
|
||||
|
||||
function isLockdownAdminRole(role) {
|
||||
@@ -17,6 +18,12 @@ export default function DatabaseAdminApp() {
|
||||
useUserIdentitySync({ identitySurface: 'passive' });
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
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 isLoggedInAdmin = role === 'admin' || isLockdownAdmin;
|
||||
|
||||
@@ -36,8 +36,14 @@ export default function ServerDisplayContent() {
|
||||
|
||||
return (
|
||||
<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 || []} />
|
||||
{/* 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 className="min-h-0 flex-[0.72]">
|
||||
<DisplayRoverGrid roster={session?.roster || []} session={session} />
|
||||
@@ -45,11 +51,6 @@ export default function ServerDisplayContent() {
|
||||
<div className="min-h-0 flex-[1.28]">
|
||||
<DisplayChatFeed />
|
||||
</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 />
|
||||
<RewardRunOverlay />
|
||||
{/* 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() {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const operatorLabel = String(ptz?.operatorLabel || '').trim();
|
||||
|
||||
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;
|
||||
}
|
||||
const visible = Boolean(ptz?.enabled && operatorLabel);
|
||||
|
||||
return (
|
||||
<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"
|
||||
aria-label={`PTZ operator ${operatorLabel}`}
|
||||
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 ${
|
||||
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
|
||||
control panel. The large name is the useful information from across the
|
||||
room, while the smaller prefix prevents the blue box from being mistaken
|
||||
for a rover driver or chat message.
|
||||
This is a flex-row segment instead of a fixed overlay so the online
|
||||
people marquee loses width when PTZ is active. That makes the badge feel
|
||||
like it enters from the right edge of the top bar while avoiding the
|
||||
previous problem where it covered content in the bottom-right corner.
|
||||
*/}
|
||||
<div className="text-7xl font-black tracking-normal text-sky-100">
|
||||
PTZ camera
|
||||
</div>
|
||||
<div className="truncate text-9xl font-black leading-none text-white">
|
||||
{operatorLabel}
|
||||
<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">
|
||||
{/*
|
||||
The user explicitly requested uppercase "PTZ" here because the room
|
||||
display needs a terse, instantly recognizable camera marker. The name
|
||||
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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -68,7 +68,10 @@ export default function OnlinePeopleStrip({ users = [] }) {
|
||||
));
|
||||
|
||||
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
|
||||
ref={trackRef}
|
||||
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 };
|
||||
}
|
||||
@@ -4,13 +4,11 @@ import { useSharedClock } from './useSharedClock.js';
|
||||
|
||||
export function useDriverVideoModePolicy(roverId) {
|
||||
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 socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const nonTurnVideoPolicy = useSessionSelector(
|
||||
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots',
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
const isTurnsMode = mode === 'turns';
|
||||
/*
|
||||
@@ -33,26 +31,13 @@ export function useDriverVideoModePolicy(roverId) {
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || 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
|
||||
video authorization. The hook only mirrors that policy so the UI avoids
|
||||
requesting live video when snapshots are the intended non-turn experience.
|
||||
The server evaluates the global controllable-user threshold because that
|
||||
same decision is enforced in socket video tokens and MediaMTX auth. This
|
||||
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 =
|
||||
nonTurnVideoPolicy === 'snapshots' && isTurnsMode && totalDrivers > totalRovers;
|
||||
const shouldUsePreviewByLoad = nonTurnSnapshotsActive && isTurnsMode;
|
||||
const isPreSwitchWindow =
|
||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||
|
||||
@@ -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
@@ -59,27 +59,40 @@ body {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.pride-page-bg {
|
||||
background-color: #050505;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0)),
|
||||
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-frame {
|
||||
/* The unscaled frame always stays inside the viewport on phones and on
|
||||
desktop browsers that do not apply the larger presentation below. */
|
||||
max-width: calc(100vw - 0.5rem);
|
||||
}
|
||||
|
||||
.inter-instance-overlay-body {
|
||||
max-height: 82vh;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.inter-instance-overlay-scale {
|
||||
/*
|
||||
`zoom` enlarges the complete interface—including typography, controls,
|
||||
spacing, and hit targets—while 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 {
|
||||
@apply bg-black text-white p-0 rounded-md;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
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 { SocketProvider } from './context/SocketContext.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 AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
@@ -37,6 +41,13 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<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>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user