Compare commits

...
24 Commits
Author SHA1 Message Date
legop3 351cb24458 slop 2026-07-17 02:15:47 -04:00
legop3 679563862d slop 2026-07-17 02:12:03 -04:00
legop3 8655cde0f1 slop 2026-07-17 01:55:59 -04:00
legop3 12090f23be slop 2026-07-17 01:22:27 -04:00
legop3 557b4b81a2 slop 2026-07-17 01:04:21 -04:00
legop3 0add90714b slop 2026-07-17 00:51:24 -04:00
legop3 fe64ec7758 slop 2026-07-17 00:14:43 -04:00
legop3 10f71edaf1 slop 2026-07-17 00:06:55 -04:00
legop3 e97d4056fa slop 2026-07-16 23:47:32 -04:00
legop3 c228bb107f slop 2026-07-16 23:30:01 -04:00
legop3 06aeca660b slop 2026-07-16 23:12:09 -04:00
legop3 4bd228547a slop 2026-07-16 22:52:03 -04:00
legop3 35561495b4 slop 2026-07-16 22:35:07 -04:00
legop3 8a9205b5b7 slop 2026-07-16 22:26:38 -04:00
legop3 a6c569ada4 slop 2026-07-16 22:09:53 -04:00
legop3 0d352d326d slop 2026-07-16 22:01:18 -04:00
legop3 7bc08af160 slop 2026-07-16 21:36:44 -04:00
legop3 9177e53fbf ptz queue stuff fix 2026-07-16 19:58:52 -04:00
legop3 5be5ad3b17 add route for ptz page 2026-07-16 19:47:20 -04:00
legop3 99bc00e96b replay panel and PTZ ui improvements 2026-07-16 19:25:18 -04:00
legop3 002b174259 fix chat ack waiting for commands to finish 2026-07-16 17:47:58 -04:00
legop3 e28ccc5e66 better /display ptz operator popup 2026-07-16 17:42:55 -04:00
legop3 efae430d65 snapshot user threshold.. 2026-07-16 17:29:00 -04:00
legop3 0c9df78070 Merge pull request #15 from legop3/commandsidequest
Commandsidequest
2026-07-16 17:09:41 -04:00
39 changed files with 3300 additions and 419 deletions
+1
View File
@@ -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
+5 -2
View File
@@ -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
```
+15 -4
View File
@@ -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
+1
View File
@@ -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');
+44 -3
View File
@@ -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
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/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-Da9ufxPv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
</head>
<body>
<div id="root"></div>
+32 -8
View File
@@ -10,7 +10,10 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
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() {
+5
View File
@@ -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
+20 -15
View File
@@ -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 }) {
@@ -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);
+33 -1
View File
@@ -339,6 +339,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
@@ -1305,7 +1333,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
@@ -1577,6 +1608,7 @@ module.exports = {
ptzCameraEvents: events,
getPublicState,
getChatTargetForSocket,
getParticipantSocketIds,
canSpeakThroughPtz,
speakText,
canRequestLiveVideo,
+54 -14
View File
@@ -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();
@@ -30,6 +30,7 @@ const { canAccessStream } = createVideoAuthPolicy({
ptzCameraService,
getSocketIp,
isLocalNetwork,
io,
});
registerVideoAuthRoute({
+28 -1
View File
@@ -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,7 +101,11 @@ function createVideoAuthPolicy(deps) {
if (!roverManager.isDriver(roverId, socket)) {
return false;
}
if (!isAudio && shouldUseSnapshotsForNonTurnVideo() && !turnService.canDrive(roverId, socket)) {
if (
!isAudio &&
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canDrive(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
@@ -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,7 +152,7 @@ io.on('connection', (socket) => {
!isAudio &&
role !== 'spectator' &&
!isAdmin(socket) &&
shouldUseSnapshotsForNonTurnVideo() &&
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canDrive(baseId, socket)
) {
/*
+3 -30
View File
@@ -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';
@@ -52,36 +53,7 @@ import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.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 +172,7 @@ function MobileFeatureTabs({
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
@@ -0,0 +1,269 @@
// Balance Board Panel
// Purpose: Shows exactly what the Bluetooth board is doing and its current total weight.
// Scope: Owns optional feature gating and the live weight-frame subscription only.
import { useEffect, useState } from 'react';
import { useSocket } from '../../context/SocketContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import CardFrame from '../CardFrame/index.jsx';
const EMPTY_CORNERS = {
topLeft: 0,
topRight: 0,
bottomLeft: 0,
bottomRight: 0,
};
const EMPTY_FRAME = {
totalKg: 0,
batteryPercent: null,
// Null distinguishes "no live frame received yet" from a legitimate record
// of zero, allowing the persisted session value to remain visible while the
// socket room subscription is being established.
recordKg: null,
recordedAt: null,
corners: EMPTY_CORNERS,
};
function formatWeight(value) {
const weight = Number(value);
return Number.isFinite(weight) ? `${weight.toFixed(2)} kg` : '0.00 kg';
}
function finiteNumber(value) {
if (value == null || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function centerOfPressure(corners) {
const topLeft = Math.max(0, finiteNumber(corners.topLeft) || 0);
const topRight = Math.max(0, finiteNumber(corners.topRight) || 0);
const bottomLeft = Math.max(0, finiteNumber(corners.bottomLeft) || 0);
const bottomRight = Math.max(0, finiteNumber(corners.bottomRight) || 0);
const total = topLeft + topRight + bottomLeft + bottomRight;
// Only an exact zero stays centered because dividing by zero cannot produce a
// position. Every positive reading participates immediately, with no minimum
// weight or center deadzone hiding small shifts reported by the load cells.
if (total === 0) return { left: 50, top: 50, active: false };
const horizontal = ((topRight + bottomRight) - (topLeft + bottomLeft)) / total;
const vertical = ((bottomLeft + bottomRight) - (topLeft + topRight)) / total;
return {
left: 50 + Math.max(-1, Math.min(1, horizontal)) * 37,
top: 50 + Math.max(-1, Math.min(1, vertical)) * 37,
active: true,
};
}
function CornerReading({ className, label, value }) {
return (
<div className={`surface absolute min-w-[5.5rem] text-center ${className}`}>
<div className="text-[0.62rem] text-slate-400">{label}</div>
<div className="text-sm font-semibold text-slate-100">{formatWeight(value)}</div>
</div>
);
}
export default function BalanceBoardPanel() {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'balanceBoard'));
// Keep feature ownership inside the component so layouts do not need special
// cases or empty wrappers when the optional hardware is disabled.
if (!enabled) return null;
return <BalanceBoardPanelContent />;
}
function BalanceBoardPanelContent() {
const socket = useSocket();
const board = useSessionSelector((state) => state.session?.balanceBoard || null);
const role = useSessionSelector((state) => state.session?.role || null);
const [frame, setFrame] = useState(EMPTY_FRAME);
const [unpairing, setUnpairing] = useState(false);
const [zeroRequesting, setZeroRequesting] = useState(false);
const [resettingRecord, setResettingRecord] = useState(false);
useEffect(() => {
if (!socket) return undefined;
const handleFrame = (next = {}) => setFrame({ ...EMPTY_FRAME, ...next });
// Socket.IO room membership belongs to one server-side connection, not to
// the long-lived browser socket object. A brief network interruption gives
// the browser a new server-side socket while React keeps this component and
// this effect mounted, so subscribing only here would silently lose all
// later weight frames. Rejoin after every connection as well as immediately
// for the already-connected case.
const subscribe = () => {
socket.emit('balanceBoard:subscribe', {}, () => {});
};
socket.on('balanceBoard:frame', handleFrame);
socket.on('connect', subscribe);
subscribe();
return () => {
socket.off('balanceBoard:frame', handleFrame);
socket.off('connect', subscribe);
// The panel is the only consumer represented by this component. Leaving
// the room on unmount prevents an inactive route or tab from continuing
// to receive the board's continuous measurement stream.
socket.emit('balanceBoard:unsubscribe');
};
}, [socket]);
// Mask the previous reading immediately when disconnected. Keeping the last
// socket frame in state avoids effect-driven state resets and stale flashes.
const liveFrame = board?.connected ? frame : EMPTY_FRAME;
const corners = { ...EMPTY_CORNERS, ...(liveFrame.corners || {}) };
const center = centerOfPressure(corners);
const liveBattery = finiteNumber(liveFrame.batteryPercent);
const sessionBattery = finiteNumber(board?.batteryPercent);
const battery = liveBattery ?? sessionBattery;
// Live frames make a newly reached record move immediately. The session copy
// remains available while the board sleeps or before this panel subscribes,
// which is important because the record belongs to the installation rather
// than to one Bluetooth connection.
const liveRecord = board?.connected ? finiteNumber(frame.recordKg) : null;
const sessionRecord = finiteNumber(board?.recordKg);
const record = liveRecord ?? sessionRecord ?? 0;
const sleeping = board?.status === 'sleeping';
const isAdmin = role === 'admin' || role === 'lockdown';
const calibration = board?.calibration || null;
const zeroing = Boolean(calibration?.active);
const zero = () => {
if (zeroRequesting || zeroing || !board?.connected) return;
if (!window.confirm('Use the boards current load as zero? Keep everything still for ten seconds.')) return;
setZeroRequesting(true);
socket.emit('balanceBoard:zero', {}, (response = {}) => {
setZeroRequesting(false);
if (response.error) window.alert(response.error);
});
};
const unpair = () => {
if (unpairing || !board?.paired) return;
if (!window.confirm('Unpair this Balance Board and require the red Sync button to pair it again?')) return;
setUnpairing(true);
socket.emit('balanceBoard:unpair', {}, (response = {}) => {
setUnpairing(false);
if (response.error) {
window.alert(response.error);
} else if (response.warning) {
window.alert('Board forgotten locally, but BlueZ reported a bond-removal warning.');
}
});
};
const resetRecord = () => {
if (resettingRecord) return;
if (!window.confirm('Reset the highest weight record?')) return;
setResettingRecord(true);
socket.emit('balanceBoard:resetRecord', {}, (response = {}) => {
setResettingRecord(false);
if (response.error) window.alert(response.error);
});
};
const actions = isAdmin ? (
<div className="flex items-center gap-0.5">
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={!board?.connected || zeroRequesting || zeroing || unpairing}
onClick={zero}
>
{zeroing
? `Zeroing ${calibration.samplesCollected}/${calibration.totalSamples}`
: zeroRequesting ? 'Starting…' : 'Zero'}
</button>
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={!board?.paired || unpairing || zeroing}
onClick={unpair}
>
{unpairing ? 'Unpairing…' : 'Unpair'}
</button>
</div>
) : null;
return (
<CardFrame
title="Balance Board"
className="relative w-full"
bodyClassName="text-sm text-slate-200"
actions={actions}
>
{sleeping ? (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/85 px-2 text-center">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-slate-100">The Balance Board is asleep</p>
<p className="text-sm text-slate-300">Press the front power button on the board to wake it.</p>
</div>
</div>
) : null}
{/* Keep the measurement column narrow and fixed so the board remains the
dominant visual while record and battery stay in one predictable
place. Both pieces use the shared dark panel treatment instead of
introducing a Balance Board-specific background style. */}
<div className="grid grid-cols-[minmax(0,1fr)_8rem] gap-0.5">
<div className="panel-section relative h-52 overflow-hidden">
{zeroing ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-neutral-950/90 px-2 text-center">
<div className="space-y-0.5">
<p className="text-lg font-semibold text-slate-100">
Zeroing {calibration.samplesCollected}/{calibration.totalSamples}
</p>
<p className="text-sm text-slate-300">Keep the board and everything on it still.</p>
</div>
</div>
) : null}
<CornerReading className="left-0.5 top-0.5" label="Top left" value={corners.topLeft} />
<CornerReading className="right-0.5 top-0.5" label="Top right" value={corners.topRight} />
<CornerReading className="bottom-0.5 left-0.5" label="Bottom left" value={corners.bottomLeft} />
<CornerReading className="bottom-0.5 right-0.5" label="Bottom right" value={corners.bottomRight} />
<div
aria-label="Center of pressure"
className={`absolute z-10 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border transition-all duration-100 ${
center.active
? 'border-sky-200 bg-sky-500'
: 'border-neutral-500 bg-neutral-600 opacity-50'
}`}
style={{ left: `${center.left}%`, top: `${center.top}%` }}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="surface px-1 py-0.5 text-center">
<div className="text-[0.65rem] text-slate-400">Total weight</div>
<div className="text-3xl font-bold leading-none text-white">
{formatWeight(liveFrame.totalKg)}
</div>
</div>
</div>
</div>
<div className="grid h-52 grid-rows-[minmax(0,1fr)_auto] gap-0.5">
<div className="panel-section flex min-h-0 flex-col items-center justify-center gap-1 text-center">
<div className="text-xs text-slate-400">Weight record</div>
<div className="text-xl font-bold text-white">{formatWeight(record)}</div>
{isAdmin ? (
<button
type="button"
className="button-dark text-xs disabled:opacity-50"
disabled={resettingRecord}
onClick={resetRecord}
>
{resettingRecord ? 'Resetting…' : 'Reset'}
</button>
) : null}
</div>
<div className="panel-section px-1 py-1 text-center">
<div className="text-xs text-slate-400">Battery</div>
<div className="text-xl font-semibold text-slate-100">
{battery == null ? '—' : `${Math.round(battery)}%`}
</div>
</div>
</div>
</div>
</CardFrame>
);
}
+255 -109
View File
@@ -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';
@@ -116,45 +118,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,7 +186,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
}
function PtzMobileZoomButtons({ disabled = false }) {
const { nudgeServo, stopAllMotion } = useControlActions();
const { nudgeServo } = useControlActions();
const repeatTimerRef = useRef(null);
const stopZoom = useCallback(() => {
@@ -238,8 +201,13 @@ function PtzMobileZoomButtons({ disabled = false }) {
clearInterval(repeatTimerRef.current);
repeatTimerRef.current = null;
}
stopAllMotion();
}, [stopAllMotion]);
/*
Zero is a zoom-only release signal in the PTZ adapter. Using the global
stop action here previously erased a simultaneously held pan/tilt vector,
making mixed touch controls unexpectedly stop the camera.
*/
nudgeServo(0);
}, [nudgeServo]);
const startZoom = useCallback(
(direction) => (event) => {
@@ -540,8 +508,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 +519,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]);
@@ -595,8 +572,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 +594,248 @@ 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 isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
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 bg-black p-2 text-slate-100">
<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 bg-black text-slate-100">
<CardFrame
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
actions={isMobile ? null : (
@@ -698,20 +850,17 @@ export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
bodyClassName="relative min-h-0 flex-1"
>
{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 +870,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 +884,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 +897,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 +933,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 +971,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';
@@ -420,6 +421,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard />
<LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel />
<OdometerPanel />
<ButtonBoxPanel />
@@ -326,8 +326,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 +336,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 +384,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 +410,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
+58 -10
View File
@@ -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 = {
@@ -98,9 +98,12 @@ 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 panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
const zoomIntentRef = useRef(0);
const zoomStopTimerRef = useRef(null);
const emitPtz = useCallback(
@@ -116,6 +119,10 @@ export function usePtzControlAdapter() {
clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null;
}
// A true global stop is used for blur, route close, and control release, so
// it deliberately clears every independently tracked PTZ axis intent.
panTiltIntentRef.current = { pan: 0, tilt: 0 };
zoomIntentRef.current = 0;
const stopSignature = payloadSignature(PTZ_STOP);
if (lastMotionSignatureRef.current === stopSignature) return;
lastMotionSignatureRef.current = stopSignature;
@@ -146,7 +153,20 @@ export function usePtzControlAdapter() {
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.
*/
sendMotion({
...panTiltIntentRef.current,
zoom: zoomIntentRef.current,
});
return true;
},
[isActive, sendMotion],
@@ -157,7 +177,17 @@ export function usePtzControlAdapter() {
if (!isActive) return false;
const sign = axisSign(direction);
if (!sign) {
stopMotion();
if (zoomStopTimerRef.current) {
clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null;
}
zoomIntentRef.current = 0;
/*
Releasing zoom must not call the global PTZ stop. Re-emit the retained
pan/tilt intent with zoom cleared so a held direction continues
immediately instead of waiting for another directional key event.
*/
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
return true;
}
/*
@@ -166,7 +196,11 @@ export function usePtzControlAdapter() {
the payload is identical, otherwise holding "camera up" only sends the
first zoom command and every later nudge is de-duped away.
*/
sendMotion({ pan: 0, tilt: 0, zoom: sign * PTZ_SPEEDS.medium }, { force: true });
zoomIntentRef.current = sign * PTZ_SPEEDS.medium;
sendMotion({
...panTiltIntentRef.current,
zoom: zoomIntentRef.current,
}, { force: true });
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
/*
Existing rover camera controls are nudge/slider based, not hold-based.
@@ -175,21 +209,31 @@ export function usePtzControlAdapter() {
*/
zoomStopTimerRef.current = setTimeout(() => {
zoomStopTimerRef.current = null;
stopMotion();
zoomIntentRef.current = 0;
// A zoom pulse ending restores, rather than stops, any direction that
// is still held in the independent pan/tilt intent.
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
}, ZOOM_PULSE_MS);
return true;
},
[isActive, sendMotion, stopMotion],
[isActive, sendMotion],
);
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,15 +242,19 @@ 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);
panTiltIntentRef.current = { pan: 0, tilt: 0 };
zoomIntentRef.current = 0;
if (zoomStopTimerRef.current) {
clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null;
@@ -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(
+7 -22
View File
@@ -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;
+33
View File
@@ -0,0 +1,33 @@
// Responsive Layout Mode Hook
// Purpose: Gives control-capable routes the same desktop, mobile-landscape,
// and mobile-portrait breakpoint policy.
// Scope: Classifies viewport geometry only; each route still owns its actual
// component arrangement so PTZ and rover controls can remain purpose-built.
import { useEffect, useState } from 'react';
function readLayoutMode() {
if (typeof window === 'undefined') return 'desktop';
if (window.innerWidth >= 1024) return 'desktop';
return window.innerWidth > window.innerHeight ? 'mobile-landscape' : 'mobile-portrait';
}
export default function useLayoutMode() {
const [mode, setMode] = useState(readLayoutMode);
useEffect(() => {
function updateMode() {
/*
Orientation changes are exposed as viewport resizes on the browsers
supported by this UI. Reading both dimensions here keeps the route
responsive without maintaining a second orientation event lifecycle.
*/
setMode(readLayoutMode());
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
+8
View File
@@ -18,6 +18,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 +38,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>
+45
View File
@@ -0,0 +1,45 @@
// Dedicated PTZ Route Root
// Purpose: Mounts the PTZ controller as a real page with the same shared input
// and identity systems used by the driver page.
// Scope: Owns route-level providers and responsive selection only; camera state,
// queue policy, and the visible controller remain in the shared PTZ component.
import AlertFeed from '../components/AlertFeed/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { PtzControllerPage } from '../components/PtzCamera/index.jsx';
import {
ControlSystemProvider,
GamepadInputManager,
KeyboardInputManager,
} from '../controls/index.js';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import useIncomingInterInstanceTransfer from '../hooks/useIncomingInterInstanceTransfer.js';
import useLayoutMode from '../hooks/useLayoutMode.js';
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
function PtzRouteContent() {
const layout = useLayoutMode();
/*
Navigating away from the driver route unmounts its identity hooks. The PTZ
route is still an active control surface, so it must keep the same driver
identity heartbeat alive instead of allowing the session to become passive
while someone operates or waits for the camera.
*/
useDefaultNickname();
useIncomingInterInstanceTransfer();
useUserIdentitySync({ identitySurface: 'driver' });
return (
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<PtzControllerPage layout={layout} />
<AlertFeed />
<SocketConnectionPill />
</ControlSystemProvider>
);
}
export default function PtzAppRoot() {
return <PtzRouteContent />;
}