Compare commits

..
49 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
legop3 9d8e22ad1e fix some wording in response to community complaints 2026-07-16 16:58:27 -04:00
legop3 385e7c25fa fixins 2026-07-16 16:45:51 -04:00
legop3 3a8a2ebb13 the big 2026-07-15 00:35:39 -04:00
legop3 96d06091ee going on a command sidequest 2026-07-14 23:47:42 -04:00
legop3 15e03e62ed page title from interinstance config name!! 2026-07-14 23:11:14 -04:00
legop3 3aa97baa4f vip only spectator option 2026-07-14 21:38:55 -04:00
legop3 017b3c69d5 spectator page selectors 2026-07-14 21:13:09 -04:00
legop3 ad7de34d6d appoint spectator access when you login from external, actually... 2026-07-14 20:42:37 -04:00
legop3 51fbee400c spectator login for exties 2026-07-14 20:32:25 -04:00
legop3 7fe5730953 idle service and lght lock improvements 2026-07-14 17:38:17 -04:00
legop3 0d6b4d68de bandwidth savings configs 2026-07-14 17:04:08 -04:00
legop3 1c401ff90a label adjustments 2026-07-14 14:47:46 -04:00
legop3 f6b9fa798e ptz operator in /display 2026-07-14 14:32:25 -04:00
legop3 f667bbce53 tryina make mini not reload stuff.. 2026-07-14 13:34:12 -04:00
legop3 f480e01bf7 dont replace mmtx config 2026-07-14 01:23:11 -04:00
legop3 e2e94da656 ptz in mini and better mini 2026-07-13 18:52:37 -04:00
legop3 8ee680ce9f let everyone make ptz presets.. .. .. .. .. . . . . 2026-07-13 17:39:13 -04:00
legop3 be37a39291 oitercurrent 2026-07-13 16:21:05 -04:00
legop3 af484f5099 oitercurrenting 2026-07-13 16:17:36 -04:00
legop3 5d8cb48fd0 update all rovers buttone 2026-07-13 16:03:51 -04:00
legop3 c742fa1c81 run self update as systemd run 2026-07-13 15:49:09 -04:00
legop3 6dc067580d reboot on self update finally 2026-07-13 15:41:17 -04:00
legop3 d9e6317220 slower iframes 2026-07-13 15:34:44 -04:00
legop3 f969e50772 Merge pull request #14 from legop3/ptz
Ptz
2026-07-13 14:55:09 -04:00
legop3 6e63f0e19c Acknowledge use of language models in project
Added acknowledgment for assistance from language models.
2026-07-11 22:04:54 -04:00
99 changed files with 5725 additions and 922 deletions
+1
View File
@@ -33,3 +33,4 @@ server/data/identity.sqlite
server/data/barcode-games.json server/data/barcode-games.json
server/data/identity.sqlite-shm server/data/identity.sqlite-shm
server/data/identity.sqlite-wal server/data/identity.sqlite-wal
server/src/services/balanceBoardService/native/balance_board_worker
+2
View File
@@ -4,6 +4,8 @@ A system for controlling create 2 compatible roombas through a webpage.
You can explore my basement through this project here: You can explore my basement through this project here:
https://rover.otter.land https://rover.otter.land
*some of this code was created with help from large language models, and some of it was written by me. This project would not have been possible for me to create without it.*
## This guide is a work in progress, it will cover: ## This guide is a work in progress, it will cover:
- Building rovers - Building rovers
- Installing roverd on a rover's raspberry pi - Installing roverd on a rover's raspberry pi
+10
View File
@@ -29,6 +29,15 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1 exit 1
fi fi
if [[ "${ROVERD_SELF_UPDATE_SYSTEMD:-}" != "1" ]] && command -v systemd-run >/dev/null 2>&1; then
exec systemd-run \
--unit=roverd-self-update \
--collect \
--property=Type=exec \
--setenv=ROVERD_SELF_UPDATE_SYSTEMD=1 \
"$0"
fi
if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2 echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
exit 1 exit 1
@@ -86,3 +95,4 @@ log "Repository fast-forward pull complete"
# drift away from the normal manual install path. # drift away from the normal manual install path.
"$ROVERD_REPO_DIR/pi/install_roverd.sh" "$ROVERD_REPO_DIR/pi/install_roverd.sh"
log "Installer completed successfully" log "Installer completed successfully"
systemctl reboot
+1
View File
@@ -120,6 +120,7 @@ run_pipeline() {
--framerate "${ROVERD_VIDEO_FPS}" \ --framerate "${ROVERD_VIDEO_FPS}" \
--bitrate "${ROVERD_VIDEO_BITRATE}" \ --bitrate "${ROVERD_VIDEO_BITRATE}" \
--codec h264 \ --codec h264 \
--intra 120 \
--profile baseline \ --profile baseline \
--denoise auto \ --denoise auto \
--nopreview \ --nopreview \
@@ -0,0 +1,359 @@
# Command system and optional Discord feature
## Purpose
Commands were originally implemented as part of the Discord bot. Web chat support was later added by adapting site chat messages into Discord-shaped messages and reusing the Discord command router. This leaves an important server capability owned by an optional external integration and creates inconsistent behavior between transports.
The command system should instead be an always-available server capability. Web chat and Discord should both be adapters for the same command system, while Discord itself becomes an optional feature that can be disabled without affecting commands or the rest of the server.
This is an internal architecture change. Existing behavior on the outside must remain unchanged unless this plan explicitly introduces a new command.
## Non-negotiable behavior
- Existing command names and syntax continue to work.
- Existing permission and lockdown rules continue to work.
- Existing web-chat command messages and replies continue to look and behave the same.
- Existing Discord replies and embeds retain the same content, titles, field ordering, colors, timestamps, mention behavior, attachment names, progress updates, and edit behavior.
- Existing Discord chat bridge, presence, moderation workflows, announcements, and other integrations continue to work when Discord is enabled.
- Disabling Discord does not disable site-chat commands, replay generation, or unrelated server features.
- Discord.js types, messages, embeds, guilds, channels, and configuration do not leak into the shared command implementation.
- Replay hosting requires no new configuration. It must be automatic, conservative, and functional.
- Backwards compatibility for obsolete internal architecture is not required after migration. Temporary migration adapters should be deleted when the new path is complete.
## Target dependency direction
```text
Web chat adapter ---------+
|
v
Operator command service ----> Existing server services
^
|
Optional Discord adapter -+
```
The operator command service owns parsing, command discovery, permission policy, execution, and neutral results. It does not know how a web-chat message or Discord message is represented.
The name `operatorCommandService` avoids confusion with the existing rover `commandService`, which sends operational commands to individual rovers.
## Command configuration
Command naming belongs to the command system rather than Discord:
```yaml
commands:
prefix: "rs"
timeStatusCommand: "ts"
```
Both web chat and Discord must read these same values. Prefix matching remains case-insensitive and must match a whole token so a prefix such as `rs` does not treat a word such as `rsvp` as a command.
Discord becomes an explicitly optional feature:
```yaml
discord:
enabled: false
token: ""
```
The existing Discord channel, role, site URL, and other settings stay under `discord`. `discord.enabled` is authoritative: a stored token must not silently enable the feature. If Discord is enabled but required credentials are missing or login fails, the failure is clearly logged and must not prevent the rest of the server from operating.
### Existing server feature system is authoritative
Use `server/src/helpers/features.js` as the single source of truth for whether optional features are configured and enabled. Do not add a command-specific feature registry, duplicate configuration checks inside command handlers, or infer availability independently from individual config fields.
- Add Discord to `buildFeatureFlags()` using the same explicit feature-gating pattern as the other optional server features. Discord is enabled only when `discord.enabled` is explicitly true and the required token is present.
- Discord service bootstrap, command-adapter registration, integrations, presence, bridge behavior, alerts, and Discord replay delivery all consult the shared Discord feature flag.
- Command definitions use `requiredFeature` metadata, and the command dispatcher resolves that metadata through `isFeatureEnabled()` or a feature-flags snapshot from the same helper.
- Help availability and command execution use the same feature result so help cannot advertise a command as available when execution considers it disabled.
- Lift and Neato availability comes from the existing `lift` and `neato` feature flags. Commands must not reproduce their Home Assistant, switch, device, or enabled-field checks.
- Configuration-level feature availability is separate from runtime health. For example, an enabled lift may currently be disconnected, and configured Discord may fail login. The shared feature helper answers whether the feature is enabled and configured; the owning service remains authoritative for runtime readiness and returns a clear operational failure.
- Replay generation and automatic local replay hosting are core server capabilities and are not feature-gated. Only the optional Discord delivery provider depends on the Discord feature flag and live Discord readiness.
When Discord is disabled:
- Do not construct a Discord client.
- Do not attempt login.
- Do not register Discord event handlers or event-bus integrations.
- Do not register Discord chat bridge subscriptions.
- Do not start Discord presence behavior.
- Keep the shared command service and all site-chat commands active.
## Neutral command request
Every transport converts its native user/message state into one normalized request:
```js
{
text: 'rs lock alpha',
source: 'web-chat',
actor: {
id: 'stable actor id',
label: 'display name',
role: 'admin',
isAdmin: true,
isLockdownAdmin: false,
},
context: {}
}
```
The web adapter derives the actor from the authenticated socket, identity, and role services. The Discord adapter derives it from the Discord user and configured administrator mapping. Command handlers consume the normalized actor and never inspect a socket or `message.author`.
Transport-specific context is allowed only for transport-specific extension commands. For example, the Discord-only bridge command needs guild and channel context, but shared commands must not depend on it.
## Command registry
Replace the large dispatcher switch and scattered help definitions with a command registry. A command definition should contain enough metadata to drive parsing, authorization, availability, and help:
```js
{
name: 'lift',
category: 'feature',
summary: 'Control the rover lift.',
description: 'Show lift state or request upward or downward movement.',
usage: ['lift status', 'lift up', 'lift down'],
examples: ['rs lift status', 'rs lift down'],
access: 'admin',
lockdownAccess: 'lockdown-admin',
requiredFeature: 'lift',
execute,
}
```
The dispatcher should be responsible for common authorization. Individual handlers may perform finer-grained checks when subcommands truly require different access, but they should not duplicate the ordinary admin and lockdown gates.
## Command categories
Categories organize registration and help. Existing syntax must not be changed merely to add categories; for example, `rs mode` stays `rs mode` rather than becoming `rs admin mode`.
### System commands
General server information and server-wide user actions:
- `rs help`
- `rs status`
- `rs replay`
- The configured time-status command, currently `ts`
- Future health, session, or informational commands that do not belong to one optional feature
### Admin commands
Operational, access, and moderation controls:
- `rs lock`
- `rs unlock`
- `rs mode`
- `rs kick`
- `rs goal`
- `rs reason`
- `rs verify`
- `rs deter`
- `rs lights`
Existing admin and lockdown-admin policies remain authoritative.
### Feature commands
Commands belonging to optional hardware or server features. Initial additions should include:
- `rs lift status`
- `rs lift up`
- `rs lift down`
- `rs neato status`
- `rs neato start`
- `rs neato home`
- `rs neato locate`
- `rs neato clear-errors`
Feature command handlers must call the existing feature services. They must not reimplement lift interlocks, cooldowns, connectivity checks, Home Assistant calls, Neato state rules, or other hardware safety logic. The feature service remains the source of truth and the command reports its result.
The dispatcher checks each command's `requiredFeature` against the existing server feature system before execution. The owning feature service then performs runtime availability and safety checks. This deliberately keeps configuration eligibility centralized in `helpers/features.js` while keeping live device state and operational rules inside the service that controls the feature.
Commands for an unavailable or disabled feature return a clear unavailable response rather than throwing or silently doing nothing.
### Discord-only commands
Discord bridge configuration is not a general server command. Keep `bridge` as a Discord extension command registered by the Discord adapter:
- `rs bridge`
- `rs bridge here`
- `rs bridge mode`
- `rs bridge off`
These commands retain their current syntax and Discord behavior but do not appear as available commands in web chat.
## Organized help
Help is generated from registry metadata so command definitions and documentation cannot drift apart.
The default help should be detailed but scannable, grouped into System, Admin, and Features. Discord-only commands can appear in a Discord section when help is requested from Discord. Help should respect the configured prefix and time-status command.
Support focused help:
- `rs help system`
- `rs help admin`
- `rs help features`
- `rs help status`
- `rs help replay`
- `rs help lift`
- `rs help neato`
- The same pattern for every registered command
Focused command help should include:
- A clear description
- Required permission level
- Availability or required feature
- Accepted usage forms
- Useful examples
- Subcommand explanations where applicable
The registry provides neutral help data. Web chat renders readable plain text. Discord uses its own renderer and must preserve the established outward style. Improving organization must not accidentally change unrelated Discord embeds such as rover status and time status.
## Neutral command results and transport rendering
Shared handlers return neutral results instead of calling `message.reply()`:
```js
{
handled: true,
ok: true,
messages: [
{
kind: 'text',
text: 'Locked Alpha.',
},
],
}
```
Simple commands should return text results. Structured results should be used only where transports benefit from different faithful presentations, such as rover status, time status, help, administrative lists, or replay progress.
Discord renderers translate neutral results into the same Discord.js reply and embed objects used today. Existing embed builders should be extracted and retained where possible instead of visually rewriting them during this architecture change.
The web adapter translates the same results into the existing `Rover bot` system messages. The current behavior where the user's command remains visible in the chat transcript should remain unchanged.
## Replay architecture
Replay generation and replay delivery are separate responsibilities:
```text
Replay request
|
v
Replay engine builds one completed MP4
|
v
Replay delivery coordinator
|-- Discord is enabled, ready, and replay channel works
| -> upload MP4 to Discord
| -> use returned Discord attachment URL
|
`-- Discord unavailable, unconfigured, or upload fails
-> store MP4 under the server data directory
-> use server-hosted media URL
|
v
Publish the playable replay media payload to clients
```
Discord remains the preferred host when it is configured for replay delivery. A Discord upload failure after a successful replay build must fall back to local hosting instead of failing the replay. The Discord failure should be logged clearly, while clients still receive a working replay.
The common client media payload should remain compatible with the current payload so `/mini`, `/display`, spectator clients, and other replay consumers behave the same. Discord-specific metadata remains present when Discord hosted the media. Locally hosted media supplies the same common playable URL and media fields without pretending to be a Discord attachment.
### Automatic local replay hosting
No replay-hosting configuration is added. Use conservative internal constants chosen after checking typical generated replay sizes.
The local media service should:
- Store completed files in `data/replays/` through the canonical data-directory helper.
- Use random, non-guessable IDs in public filenames.
- Expose a deliberate route such as `/media/replays/:id.mp4` rather than placing runtime media in built web assets.
- Support HTTP range requests so browsers can seek and play MP4 files normally.
- Set the correct media type and safe cache headers.
- Write atomically by completing a temporary file and renaming it into place.
- Never expose or delete a file that is still being written.
- Remove abandoned temporary files.
- Delete expired replay files during server startup.
- Run one lightweight periodic cleanup while the server is running.
- Stop the cleanup timer during graceful shutdown if the server has a shutdown lifecycle.
- Enforce both a conservative age limit and a conservative total storage ceiling.
- Delete the oldest completed files first when the storage ceiling is exceeded.
- Treat cleanup errors as logged, nonfatal maintenance failures.
- Prevent path traversal and serve only known replay filenames from the replay directory.
Cleanup must operate only on the hosted replay directory and must not touch replay frame caches, unrelated data files, or active replay builds.
## Optional Discord feature boundary
The Discord feature owns:
- Discord client creation and login
- Intents and partials
- Discord message-to-command adaptation
- Neutral-result-to-Discord rendering
- Existing embed presentation
- Discord replay upload delivery
- Chat bridge and webhook behavior
- Guild bridge storage and bridge commands
- Presence
- Discord announcements and alerts
- DM verification and private-access moderation workflows
- Reactions and Discord event handling
Discord must be added to and activated through the existing server feature system. The Discord entrypoint must not maintain a separate interpretation of `discord.enabled` and token availability. Runtime client readiness may still be tracked inside the Discord feature for operations such as replay upload, but that readiness supplements rather than replaces the shared configuration feature flag.
The Discord feature may import the operator command service. The operator command service, replay engine, chat service, and feature command handlers must not import the Discord feature or Discord.js.
## Focused regression protection
The existing implementation is the reference for current command wording and behavior. Read and preserve that behavior while moving each handler; do not first catalogue every reply or build exhaustive snapshots for all commands.
Use focused tests and practical checks at the boundaries most likely to cause meaningful regressions:
- Discord status and time-status embeds retain their existing content, structure, colors, field order, timestamps, and links.
- Discord replay progress edits, attachment upload, filename, URL extraction, and client media publication continue to work.
- A failed or unavailable Discord replay delivery falls back to working locally hosted media.
- Commands remain operational when Discord is disabled or fails login.
- Web chat and Discord use the same configured prefix and whole-token matching behavior.
- Admin and lockdown permissions are enforced consistently from both transports.
- Disabled feature commands return a clear unavailable result, while enabled feature commands use their owning service's runtime safety checks.
- Hosted replay routes support playback and seeking, reject invalid paths, and cleanup only expired completed media.
Use direct inspection and practical command checks for ordinary response wording. Additional tests are appropriate when complex logic is extracted, but exhaustive output transcription is not a prerequisite for the refactor.
## Implementation sequence
Build directly toward the final architecture. It is acceptable to move commands in logical groups while working, but avoid investing in a durable old/new compatibility framework. Once a replacement path works, remove the obsolete adapter and duplicated implementation.
1. Add the operator command request, actor, result, parser, registry, authorization, and help foundations.
2. Extract existing Discord formatting and embed construction into transport-owned renderers without changing their output.
3. Move existing system and admin commands into the registry, using their current code as the behavioral reference.
4. Move status and time status while separating neutral data collection from unchanged Discord embed rendering.
5. Add organized registry-driven help with transport-specific output.
6. Add lift and Neato feature command families using the existing feature flags, services, and safety rules.
7. Add the automatic local replay media store, HTTP route, range serving, startup cleanup, periodic cleanup, and storage limits.
8. Split replay generation from delivery and add the Discord-preferred/local-fallback delivery coordinator.
9. Move replay onto the shared command service while preserving existing Discord progress and upload behavior.
10. Convert web chat and Discord to the shared command service and move bridge commands into the Discord-only extension registry.
11. Add Discord to the existing feature system and gate all Discord bootstrap and integrations through it.
12. Remove the Discord-owned shared router, fake Discord message objects, web replay command injection, result-flattening workaround, and duplicate replay paths.
13. Add or update focused tests for the high-risk boundaries listed above.
14. Run server tests, practical command checks, the web UI build, and targeted lint for touched files.
## Completion criteria
- The server has one transport-neutral command registry and execution path.
- Web chat commands work with Discord completely disabled.
- Discord consumes the shared command service as an optional adapter.
- The configured command prefix behaves consistently everywhere.
- Help is organized by System, Admin, Features, and Discord-only extensions where applicable.
- Detailed per-command and per-category help is available.
- Lift and Neato commands use existing service safety and availability behavior.
- Discord-hosted replays behave exactly as before when Discord delivery succeeds.
- Replays automatically fall back to maintained server-hosted media without configuration.
- Existing clients continue receiving compatible replay media payloads.
- Existing Discord embeds and outward behavior remain unchanged.
- Temporary adapters and duplicated command logic are removed.
+23 -7
View File
@@ -1,16 +1,32 @@
# make all bandwidth saving options toggleable in one centralized server config # make all bandwidth saving options toggleable in one centralized server config
- external spectators are people outside of local network
- multitab protection mode - multitab protection mode
- allowed - allowed
- verified only - verified only
- not allowed - not allowed
- snapshots - snapshots
- non-turn snapshots - non-turn video
- on (you see snapshots when its not your turn) - snapshots (rover non-active turn holders and PTZ non-operators see snapshots after the user threshold is exceeded)
- off (everyone gets full video all the time) - live (rover non-active turn holders and PTZ non-operators can get full video)
- non-local spectator snapshots - userThreshold (snapshots turn on when controllable users exceed this number)
- on (external spectators are only allowed snapshots) - external spectator video
- off (all spectators get full video) - snapshots (external spectators are only allowed snapshots)
- live (external spectators can get full video)
- external spectator access (new) - external spectator access (new)
- off (no one can access the spectate page externally) - off (no one can access the spectate page externally)
- on (everyone can access the spectate page externally) - on (everyone can access the spectate page externally)
- anything else related to bandwidth savings should also get config - verifiedOnly (only verified identities can access the spectate page externally)
- admin (external spectators need a saved spectatorAccess.external identity grant)
- anything else related to bandwidth savings should also get config
## implemented config shape
```yaml
bandwidthSavings:
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
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
```
+41 -9
View File
@@ -54,6 +54,32 @@ media:
# Example: http://192.168.0.86:8889/video # Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video" whepBaseUrl: "http://192.168.0.86:8889/video"
bandwidthSavings:
# Duplicate driver-tab handling for the same browser identity.
# allowed: no duplicate-tab protection
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
# notAllowed: every identity is limited to one driver tab
multiTabProtection: "verifiedOnly"
# 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"
# Whether non-local users may enter the spectator page.
# off: block external spectators
# on: allow external spectators
# verifiedOnly: require a verified identity, but no separate spectator grant
# admin: require an identity feature-state grant at spectatorAccess.external
externalSpectatorAccess: "on"
audioForward: audioForward:
enabled: true enabled: true
ffmpegBin: "ffmpeg" ffmpegBin: "ffmpeg"
@@ -151,29 +177,35 @@ kinect:
# camera cache; it only gates browser-requested broadcasts. # camera cache; it only gates browser-requested broadcasts.
captureCooldownMs: 10000 captureCooldownMs: 10000
balanceBoard:
# The server installer always prepares Bluetooth and the kernel driver. This
# switch only starts the service and shows its small live-weight panel.
enabled: false
buttonBox: buttonBox:
enabled: false enabled: false
barcodeScanner: barcodeScanner:
enabled: false enabled: false
commands:
# Commands are a core server capability shared by site chat and optional
# transports. Their names therefore do not belong to Discord configuration.
prefix: "rs"
# Set this to null to disable the legacy bare time-status shortcut.
timeStatusCommand: "ts"
discord: discord:
# Discord is optional. A token by itself never enables an external login.
enabled: false
token: "DISCORD_BOT_TOKEN" token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to guildId: "123456789012345678" # optional; bot works in any guild it's invited to
siteUrl: "https://rover.example.com" siteUrl: "https://rover.example.com"
# Give each bot instance a unique command prefix when several rover servers
# share one Discord server. Commands are matched as whole tokens, so "rs"
# handles "rs status" but ignores normal words like "rsvp".
commandPrefix: "rs"
# Set this to null to disable the bare time-status shortcut. It is separate
# from commandPrefix because the legacy command is just "ts", and multiple
# bots in the same Discord server should not all answer the same bare word.
timeStatusCommand: "ts"
channels: channels:
general: "123456789012345678" general: "123456789012345678"
announcements: "123456789012345678" announcements: "123456789012345678"
adminAlerts: "123456789012345678" adminAlerts: "123456789012345678"
# chat bridge is configured per guild via `<commandPrefix> bridge` commands # chat bridge is configured per guild via the shared `commands.prefix`
replay: "123456789012345678" replay: "123456789012345678"
humanAlerts: "123456789012345678" humanAlerts: "123456789012345678"
roles: roles:
+4
View File
@@ -46,8 +46,12 @@ require('./src/services/buttonBoxService');
require('./src/services/barcodeScannerService'); require('./src/services/barcodeScannerService');
require('./src/services/barcodeGameService'); require('./src/services/barcodeGameService');
require('./src/services/kinectService'); require('./src/services/kinectService');
require('./src/services/balanceBoardService');
require('./src/services/sessionService'); require('./src/services/sessionService');
require('./src/services/batteryManager'); require('./src/services/batteryManager');
require('./src/services/replayEngineV2'); require('./src/services/replayEngineV2');
// Replay delivery is a core service. It must subscribe before the optional
// Discord feature so web requests always have a local delivery path.
require('./src/services/replayDeliveryService');
require('./src/services/discordBotService'); require('./src/services/discordBotService');
require('./src/services/httpServer'); require('./src/services/httpServer');
+50 -6
View File
@@ -16,6 +16,8 @@ MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots" SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments" REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules" KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This installer must be run with sudo/root." >&2 echo "This installer must be run with sudo/root." >&2
@@ -30,6 +32,8 @@ fi
TARGET_USER="$SUDO_USER" TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR" SERVER_DIR="$SCRIPT_DIR"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml" CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml" MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh" ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
@@ -134,7 +138,11 @@ dnf install -y \
gstreamer1-rtsp-server \ gstreamer1-rtsp-server \
libfreenect \ libfreenect \
libfreenect-devel \ libfreenect-devel \
libusb1-devel >/dev/null libusb1-devel \
bluez \
wiiuse \
wiiuse-devel \
libcap >/dev/null
NODE_BIN="$(command -v node)" NODE_BIN="$(command -v node)"
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE" echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
@@ -166,12 +174,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make" runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
fi fi
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
echo " Building native Balance Board bridge..."
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
exit 1
fi
# Only this small audited bridge needs the management socket used for the
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
# front-button reconnects. Never grant either capability to node or the full
# multirover service executable.
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
fi
if [[ ! -f "$CONFIG_PATH" ]]; then if [[ ! -f "$CONFIG_PATH" ]]; then
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH" cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH" chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service." echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
fi fi
# Bluetoothd remains responsible for discovery and the one-time bond, but its
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
# 0x13 before the Balance Board worker can listen for the board's front-button
# reconnect. This dedicated rover server gives those two HID listeners to the
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
# required by systemd before replacing the vendor unit's command in a drop-in.
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
EOF
chmod 0644 "$BLUETOOTH_OVERRIDE"
systemctl daemon-reload
systemctl enable bluetooth.service
systemctl restart bluetooth.service
tmpdir=$(mktemp -d) tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT trap 'rm -rf "$tmpdir"' EXIT
@@ -229,9 +268,12 @@ if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2 echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
exit 1 exit 1
fi fi
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG" if [[ -f "$MEDIAMTX_CONFIG" ]]; then
rm -f "$MEDIAMTX_CONFIG" echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG" else
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
fi
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN" echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN" install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR" chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
@@ -263,8 +305,8 @@ EOF
cat > "$MULTIROVER_SERVICE" <<EOF cat > "$MULTIROVER_SERVICE" <<EOF
[Unit] [Unit]
Description=Multi-Roomba Rover control server Description=Multi-Roomba Rover control server
After=network-online.target mediamtx.service After=network-online.target mediamtx.service bluetooth.service
Wants=network-online.target Wants=network-online.target bluetooth.service
[Service] [Service]
User=$TARGET_USER User=$TARGET_USER
@@ -301,3 +343,5 @@ echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters." echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Kinect/libfreenect packages and udev permissions were installed." echo "Kinect/libfreenect packages and udev permissions were installed."
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies." echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D5vPzVhj.js"></script> <script type="module" crossorigin src="/assets/index-Da9ufxPv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BN3kEVFL.css"> <link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+134
View File
@@ -0,0 +1,134 @@
// Bandwidth Savings Helper
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
// making individual services know raw YAML defaults or legacy config shapes.
const { loadConfig } = require('./configLoader');
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
const VIDEO_MODES = new Set(['snapshots', 'live']);
const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'admin']);
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
multiTabProtection: 'verifiedOnly',
nonTurnVideo: Object.freeze({
mode: 'snapshots',
userThreshold: 0,
}),
externalSpectatorVideo: 'snapshots',
externalSpectatorAccess: 'on',
});
function normalizeEnum(value, allowed, fallback) {
/*
Config files are hand-edited on the server, so a typo should not crash the
process or silently broaden access. Each option falls back to the current
conservative behavior unless it exactly matches a known value.
*/
const normalized = typeof value === 'string' ? value.trim() : '';
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 {
multiTabProtection: normalizeEnum(
raw.multiTabProtection,
MULTI_TAB_MODES,
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
),
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
externalSpectatorVideo: normalizeEnum(
raw.externalSpectatorVideo,
VIDEO_MODES,
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorVideo,
),
externalSpectatorAccess: normalizeEnum(
raw.externalSpectatorAccess,
EXTERNAL_SPECTATOR_ACCESS_MODES,
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorAccess,
),
};
}
function getBandwidthSavingsPolicy() {
/*
loadConfig() is cached by configLoader, so rebuilding this small object per
caller is cheap while still letting tests pass explicit config objects into
buildBandwidthSavingsPolicy().
*/
return buildBandwidthSavingsPolicy(loadConfig());
}
function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } = {}) {
const { multiTabProtection } = getBandwidthSavingsPolicy();
if (multiTabProtection === 'allowed') return false;
if (multiTabProtection === 'notAllowed') return true;
/*
verifiedOnly preserves the old behavior: trusted users can run multiple
driver tabs for operations/testing, while anonymous users are limited to one
active driver surface for fairness and bandwidth.
*/
return !isVerified && !isAdmin;
}
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() {
return getBandwidthSavingsPolicy().externalSpectatorVideo === 'snapshots';
}
function canUseExternalSpectatorAccess({
isLocal = false,
isAdmin = false,
isVerified = false,
hasGrant = false,
} = {}) {
/*
Local/LAN spectators are not the upload-bandwidth problem, and admins need
to retain access for maintenance. The configured external mode only applies
to ordinary non-local spectator sockets.
*/
if (isLocal || isAdmin) return true;
const { externalSpectatorAccess } = getBandwidthSavingsPolicy();
if (externalSpectatorAccess === 'off') return false;
if (externalSpectatorAccess === 'verifiedOnly') return Boolean(isVerified);
if (externalSpectatorAccess === 'admin') return Boolean(hasGrant);
return true;
}
module.exports = {
DEFAULT_BANDWIDTH_SAVINGS,
buildBandwidthSavingsPolicy,
getBandwidthSavingsPolicy,
shouldEnforceSingleDriverTab,
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
canUseExternalSpectatorAccess,
};
+13
View File
@@ -46,10 +46,12 @@ function buildFeatureFlags(config = loadConfig()) {
const kinectConfig = config.kinect || {}; const kinectConfig = config.kinect || {};
const buttonBoxConfig = config.buttonBox || {}; const buttonBoxConfig = config.buttonBox || {};
const barcodeScannerConfig = config.barcodeScanner || {}; const barcodeScannerConfig = config.barcodeScanner || {};
const balanceBoardConfig = config.balanceBoard || {};
const barcodeGamesConfig = config.barcodeGames || {}; const barcodeGamesConfig = config.barcodeGames || {};
const socialsConfig = config.socials || {}; const socialsConfig = config.socials || {};
const interInstanceConfig = config.interInstance || {}; const interInstanceConfig = config.interInstance || {};
const ptzCameraConfig = config.ptzCamera || {}; const ptzCameraConfig = config.ptzCamera || {};
const discordConfig = config.discord || {};
const homeAssistant = Boolean( const homeAssistant = Boolean(
asBoolean(homeAssistantConfig.enabled) && asBoolean(homeAssistantConfig.enabled) &&
asTrimmedString(homeAssistantConfig.url) && asTrimmedString(homeAssistantConfig.url) &&
@@ -67,6 +69,10 @@ function buildFeatureFlags(config = loadConfig()) {
kinect: asBoolean(kinectConfig.enabled), kinect: asBoolean(kinectConfig.enabled),
buttonBox: asBoolean(buttonBoxConfig.enabled), buttonBox: asBoolean(buttonBoxConfig.enabled),
barcodeScanner, barcodeScanner,
// The worker performs its own runtime availability reporting. Advertising
// the feature from the explicit config switch lets the UI show useful
// commissioning and hardware-error states even before a board is paired.
balanceBoard: asBoolean(balanceBoardConfig.enabled),
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)), barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
lift: Boolean( lift: Boolean(
homeAssistant && homeAssistant &&
@@ -87,6 +93,13 @@ function buildFeatureFlags(config = loadConfig()) {
asTrimmedString(ptzCameraConfig.username) && asTrimmedString(ptzCameraConfig.username) &&
asTrimmedString(ptzCameraConfig.password), asTrimmedString(ptzCameraConfig.password),
), ),
/*
Discord is an optional transport, not a prerequisite for chat commands.
Requiring both the explicit switch and a token prevents an old token from
silently enabling external connections on installations that have chosen
to run without the integration.
*/
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
}; };
} }
+7 -13
View File
@@ -1,10 +1,8 @@
// Reward Definition: Darkness // Reward Definition: Darkness
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution. // Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
const DURATION_MS = 15 * 60 * 1000; const DURATION_MS = 15 * 60 * 1000;
const LIGHT_ENFORCE_TICK_MS = 3000;
let activeTimer = null; let activeTimer = null;
let enforceLightsTimer = null;
let headlightLockUntil = 0; let headlightLockUntil = 0;
function isHeadlightBlocked() { function isHeadlightBlocked() {
@@ -16,10 +14,6 @@ function clearTimers() {
clearTimeout(activeTimer); clearTimeout(activeTimer);
activeTimer = null; activeTimer = null;
} }
if (enforceLightsTimer) {
clearInterval(enforceLightsTimer);
enforceLightsTimer = null;
}
} }
async function forceAllLightsOff(ctx) { async function forceAllLightsOff(ctx) {
@@ -55,7 +49,6 @@ async function stopDarkness(ctx, effect = {}) {
if (prevLockState === 'on' || prevLockState === 'off') { if (prevLockState === 'on' || prevLockState === 'off') {
await ctx.setHomeAssistantLightsLockedOn(true, { await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darknessRestore', source: 'buttonbox:darknessRestore',
forceApply: true,
targetState: prevLockState, targetState: prevLockState,
}); });
} else { } else {
@@ -92,7 +85,6 @@ async function startDarkness(ctx, effect) {
try { try {
await ctx.setHomeAssistantLightsLockedOn(true, { await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness', source: 'buttonbox:darkness',
forceApply: true,
targetState: 'off', targetState: 'off',
}); });
} catch (err) { } catch (err) {
@@ -100,11 +92,13 @@ async function startDarkness(ctx, effect) {
} }
ctx.saveEffect('darkness', effect); ctx.saveEffect('darkness', effect);
enforceLightsTimer = setInterval(() => { /*
forceAllLightsOff(ctx).catch((err) => { Darkness locks the room-light policy off and performs the initial off
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message }); command through setHomeAssistantLightsLockedOn above. It deliberately does
}); not keep a polling interval that re-forces Home Assistant entities off:
}, LIGHT_ENFORCE_TICK_MS); after the lock is established, out-of-band manual controls must remain able
to change individual room lights without the server fighting them.
*/
activeTimer = setTimeout(() => { activeTimer = setTimeout(() => {
stopDarkness(ctx, effect).catch((err) => { stopDarkness(ctx, effect).catch((err) => {
+107 -1
View File
@@ -8,9 +8,20 @@ const { loadConfig } = require('../../helpers/configLoader');
const { clearLockdownTimer } = require('../lockdownGuard'); const { clearLockdownTimer } = require('../lockdownGuard');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { setRole } = require('../roleService'); const { setRole } = require('../roleService');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const {
canUseExternalSpectatorAccess,
getBandwidthSavingsPolicy,
} = require('../../helpers/bandwidthSavings');
const {
getFeatureState,
getUserIdForSocket,
updateFeatureState,
} = require('../identityService');
const config = loadConfig(); const config = loadConfig();
const admins = config.admins || []; const admins = config.admins || [];
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
function findAdmin(username) { function findAdmin(username) {
return admins.find((admin) => admin.username === username); return admins.find((admin) => admin.username === username);
@@ -36,9 +47,91 @@ function isLockdownAdmin(socket) {
return socket?.data?.role === 'lockdown'; return socket?.data?.role === 'lockdown';
} }
function hasExternalSpectatorGrant(socket) {
const userId = getUserIdForSocket(socket);
if (!userId) return false;
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
/*
The identity database already owns per-user feature state. Keeping the grant
as a tiny namespaced boolean avoids a new table and lets the existing admin
database editor grant/revoke external spectator access immediately.
*/
return Boolean(state?.external);
}
function canBecomeSpectator(socket) {
const ip = getSocketIp(socket);
const local = isLocalNetwork(ip);
return canUseExternalSpectatorAccess({
isLocal: local,
isAdmin: isAdmin(socket),
isVerified: Boolean(socket?.data?.isVerified),
hasGrant: hasExternalSpectatorGrant(socket),
});
}
function externalSpectatorAccessError() {
const mode = getBandwidthSavingsPolicy().externalSpectatorAccess;
if (mode === 'verifiedOnly') {
return 'External spectator access requires a verified identity.';
}
if (mode === 'admin') {
return 'External spectator access requires admin approval for this identity.';
}
return 'External spectator access is disabled.';
}
function grantExternalSpectatorAccessAfterAdminLogin(socket) {
const policy = getBandwidthSavingsPolicy();
if (policy.externalSpectatorAccess !== 'admin') {
return false;
}
const ip = getSocketIp(socket);
if (isLocalNetwork(ip)) {
return false;
}
const userId = getUserIdForSocket(socket);
if (!userId) {
/*
Sockets are normally identified on connection before login, but keeping a
guard here makes the admin grant fail closed instead of writing an orphan
feature-state row if identity setup changes later.
*/
logger.warn('External spectator grant skipped because socket has no identity', { socketId: socket?.id });
return false;
}
updateFeatureState(
userId,
SPECTATOR_ACCESS_NAMESPACE,
(current) => ({
/*
Preserve any future spectatorAccess settings beside `external`. The
login flow is only approving this identity for external spectating, not
resetting the whole namespace back to a one-field object.
*/
...(current || {}),
external: true,
grantedByAdminLoginAt: Date.now(),
grantedByAdminUsername: socket?.data?.user?.username || null,
}),
{},
);
logger.info('External spectator access granted after admin login', {
socketId: socket.id,
userId,
username: socket?.data?.user?.username || null,
});
return true;
}
io.on('connection', (socket) => { io.on('connection', (socket) => {
const requestedRole = socket.handshake?.query?.role; const requestedRole = socket.handshake?.query?.role;
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user'; /*
Role is assigned before the browser's full identity heartbeat has completed.
For admin-gated external spectators, fail closed here; the spectator page can
identify the socket and then retry session:setRole once the grant exists.
*/
const initialRole = requestedRole === 'spectator' && canBecomeSpectator(socket) ? 'spectator' : 'user';
setRole(socket, initialRole); setRole(socket, initialRole);
logger.info('Socket connected with role', socket.id, initialRole); logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole }); socket.emit('auth:role', { role: initialRole });
@@ -51,6 +144,13 @@ io.on('connection', (socket) => {
const role = admin.lockdown ? 'lockdown' : 'admin'; const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id }; socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role); setRole(socket, role);
/*
In admin-gated external spectator mode, logging in from /spectate is the
approval action for this browser identity. Persist the grant before the
client retries switching back to spectator, otherwise the user would
lose the admin bypass and immediately fall back into the gate.
*/
grantExternalSpectatorAccessAfterAdminLogin(socket);
socket.emit('auth:role', { role }); socket.emit('auth:role', { role });
clearLockdownTimer(socket); clearLockdownTimer(socket);
logger.info('Login success', socket.id, role); logger.info('Login success', socket.id, role);
@@ -63,6 +163,12 @@ io.on('connection', (socket) => {
function handleRoleChange({ role } = {}, cb = () => {}) { function handleRoleChange({ role } = {}, cb = () => {}) {
if (role === 'spectator' || role === 'user') { if (role === 'spectator' || role === 'user') {
if (role === 'spectator' && !canBecomeSpectator(socket)) {
const error = externalSpectatorAccessError();
logger.info('Spectator role denied by bandwidth policy', socket.id, { error });
cb({ error });
return;
}
setRole(socket, role); setRole(socket, role);
socket.emit('auth:role', { role }); socket.emit('auth:role', { role });
logger.info('Role changed via client request', socket.id, role); logger.info('Role changed via client request', socket.id, role);
@@ -0,0 +1,179 @@
// Balance Board Hardware Bridge
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
// Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js.
const { spawn } = require('child_process');
const EventEmitter = require('events');
const path = require('path');
const WORKER_PATH =
process.env.BALANCE_BOARD_WORKER ||
path.join(__dirname, 'native', 'balance_board_worker');
const RESTART_DELAY_MS = 2000;
const STDERR_LOG_INTERVAL_MS = 5000;
function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) {
const events = new EventEmitter();
let worker = null;
let stdoutBuffer = '';
let stopped = false;
let restarting = false;
let restartTimer = null;
let lastStderrLogAt = 0;
let suppressedStderrLines = 0;
let currentAddress = address;
function emitProtocolError(message) {
events.emit('message', {
type: 'status',
state: 'error',
error: message,
});
}
function processStdout(chunk) {
stdoutBuffer += chunk.toString('utf8');
let newline = stdoutBuffer.indexOf('\n');
while (newline !== -1) {
const line = stdoutBuffer.slice(0, newline).trim();
stdoutBuffer = stdoutBuffer.slice(newline + 1);
if (line) {
try {
const message = JSON.parse(line);
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
throw new Error('message needs a type');
}
events.emit('message', message);
} catch (err) {
// A corrupted stdout line means measurement framing can no longer be
// trusted. Surface the exact line rather than silently discarding a
// potential hardware failure that would otherwise look like zero kg.
emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`);
logger?.warn?.('Balance Board worker protocol error', { line, error: err.message });
}
}
newline = stdoutBuffer.indexOf('\n');
}
}
function scheduleRestart() {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => {
restartTimer = null;
start();
}, RESTART_DELAY_MS);
}
function start() {
if (stopped || (worker && !worker.killed)) return;
stdoutBuffer = '';
const child = spawn(WORKER_PATH, [], {
env: {
...process.env,
BALANCE_BOARD_ADDRESS: currentAddress || '',
BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
worker = child;
child.stdout.on('data', processStdout);
child.stderr.on('data', (chunk) => {
const text = chunk.toString('utf8').trim();
if (!text) return;
const now = Date.now();
if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) {
const suffix = suppressedStderrLines
? ` (${suppressedStderrLines} worker stderr lines suppressed)`
: '';
logger?.warn?.(`Balance Board worker: ${text}${suffix}`);
lastStderrLogAt = now;
suppressedStderrLines = 0;
} else {
suppressedStderrLines += 1;
}
});
child.on('error', (err) => {
if (worker === child) worker = null;
emitProtocolError(`balance board worker failed to start: ${err.message}`);
scheduleRestart();
});
child.on('close', (code, signal) => {
if (worker === child) worker = null;
if (!stopped) {
// Admin unpair deliberately replaces the worker with an empty address.
// Do not turn that expected exit into a red hardware-error state while
// still using the normal restart scheduler for the replacement.
if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`);
restarting = false;
scheduleRestart();
}
});
}
function stop() {
stopped = true;
restarting = false;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (!worker) return;
const child = worker;
worker = null;
try {
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
} catch (_err) {
// The worker may have already closed stdin while its exit event is still
// queued. SIGTERM below remains the reliable cleanup path.
}
child.kill('SIGTERM');
setTimeout(() => {
// bluetoothctl may still be finishing a bounded pairing command inside a
// worker thread. Do not let that delay server shutdown indefinitely.
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
}, 1500).unref();
}
function restart() {
if (stopped) return;
if (!worker) {
start();
return;
}
const child = worker;
restarting = true;
try {
// An admin forget changes the address used in the child environment. A
// controlled restart lets the replacement worker start with that new
// value, while the existing close handler remains the single owner of
// delayed respawn and avoids overlapping Bluetooth listeners.
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
} catch (_err) {
// The child may have already closed stdin; SIGTERM below still guarantees
// that it cannot keep listening for the address that was just forgotten.
}
child.kill('SIGTERM');
setTimeout(() => {
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
}, 1500).unref();
}
return {
events,
start,
stop,
restart,
setAddress(nextAddress) {
// The factory can be created before first commissioning. Preserve the
// newly paired address for later bridge restarts in the same Node process
// instead of reverting the replacement worker to discovery mode.
currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : '';
},
};
}
module.exports = {
createBalanceBoardHardware,
};
@@ -0,0 +1,575 @@
// Balance Board Service
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
// Scope: Stores pairing and admin zero calibration, then publishes status plus live four-corner weight.
const fs = require('fs');
const { execFile } = require('child_process');
const { promisify } = require('util');
const EventEmitter = require('events');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('balanceBoardService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isFeatureEnabled } = require('../../helpers/features');
const { isAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const { createBalanceBoardHardware } = require('./hardware');
const events = new EventEmitter();
const enabled = isFeatureEnabled('balanceBoard');
const rawConfig = loadConfig().balanceBoard || {};
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('balance-board.json');
const FRAME_ROOM = 'balance-board-viewers';
const CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
const ZERO_SAMPLE_COUNT = 10;
const ZERO_SAMPLE_INTERVAL_MS = 1000;
const ZERO_MAX_SAMPLE_AGE_MS = 1500;
const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
const RECORD_PERSIST_DELAY_MS = 1000;
const execFileAsync = promisify(execFile);
const ALERT_COLOR = '#38bdf8';
function emptyZeroCorners() {
return Object.fromEntries(CORNER_KEYS.map((key) => [key, 0]));
}
function normalizeStoredCorners(value) {
if (!value || typeof value !== 'object') return emptyZeroCorners();
return Object.fromEntries(CORNER_KEYS.map((key) => {
const number = Number(value[key]);
return [key, Number.isFinite(number) ? Math.max(0, number) : 0];
}));
}
function emptyStore() {
return {
address: '',
zeroCorners: emptyZeroCorners(),
zeroedAt: null,
recordKg: 0,
recordedAt: null,
};
}
function loadStore() {
try {
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
const recordKg = Number.isFinite(Number(parsed?.recordKg))
? roundedWeight(parsed.recordKg)
: 0;
const recordedAt = Number.isFinite(Number(parsed?.recordedAt))
? Number(parsed.recordedAt)
: null;
return {
address,
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
zeroedAt,
recordKg,
recordedAt: recordKg > 0 ? recordedAt : null,
};
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
return emptyStore();
}
}
function persistStore() {
fs.mkdirSync(DATA_DIR, { recursive: true });
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
fs.renameSync(temporary, STORE_PATH);
}
function roundedWeight(value) {
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
}
function cornerWeightsKg(corners = {}) {
// Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
// admin zero calibration below is an installation baseline layered on top of
// this factory conversion; it must never replace the hardware calibration.
return {
topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
bottomRight: roundedWeight((Number(corners.bottomRight) || 0) / 100),
topLeft: roundedWeight((Number(corners.topLeft) || 0) / 100),
bottomLeft: roundedWeight((Number(corners.bottomLeft) || 0) / 100),
};
}
function subtractZero(rawCorners) {
const baseline = store.zeroedAt ? store.zeroCorners : emptyZeroCorners();
return Object.fromEntries(CORNER_KEYS.map((key) => [
key,
roundedWeight(Math.max(0, rawCorners[key] - baseline[key])),
]));
}
function totalCornerWeight(corners) {
return roundedWeight(CORNER_KEYS.reduce((total, key) => total + corners[key], 0));
}
let store = enabled ? loadStore() : emptyStore();
let hardware = null;
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
let detail = enabled
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
: 'Balance Board support is disabled.';
let connected = false;
let batteryPercent = null;
let latestFrame = null;
let latestRawCorners = null;
let latestRawFrameAt = 0;
let zeroTimer = null;
let recordPersistTimer = null;
let zeroSamples = [];
let zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
let previousWorkerState = '';
let lastAlertKey = '';
let unpairing = false;
function sendRawAlert(state, message = '') {
const rawMessage = message ? `${state}: ${message}` : state;
if (rawMessage === lastAlertKey) return;
lastAlertKey = rawMessage;
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
}
function sendStatusAlert(workerState, message = '') {
const shouldAlert =
workerState === 'connected' ||
workerState === 'sleeping' ||
workerState === 'connection-failed' ||
workerState === 'error' ||
(workerState === 'waiting' && previousWorkerState === 'connected');
previousWorkerState = workerState;
if (!shouldAlert) return;
// Keep the alert at the same system-level boundary as the worker protocol:
// state first, followed by its exact detail when one exists. The service does
// not reinterpret failures as friendlier product copy, but still collapses
// identical retries so a failing reconnect cannot flood the activity feed.
sendRawAlert(workerState, message);
}
function getState() {
return {
enabled,
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
connected,
status,
detail,
batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
calibration: {
calibrated: Boolean(store.zeroedAt),
zeroedAt: store.zeroedAt,
...zeroProgress,
},
};
}
function clearRecordPersistTimer() {
if (!recordPersistTimer) return;
clearTimeout(recordPersistTimer);
recordPersistTimer = null;
}
function scheduleRecordPersistence() {
clearRecordPersistTimer();
// A person driving onto the board produces many successively larger frames.
// Waiting until the maximum has stopped changing prevents a synchronous JSON
// rewrite for every 20 Hz sensor frame while still saving a settled record
// promptly enough to survive an ordinary service restart.
recordPersistTimer = setTimeout(() => {
recordPersistTimer = null;
persistStore();
}, RECORD_PERSIST_DELAY_MS);
recordPersistTimer.unref?.();
}
function publishLatestFrame() {
if (!latestFrame) return;
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
}
function resetWeightRecord() {
clearRecordPersistTimer();
// Reset means "start measuring the record from now." If the board currently
// has a load, that current measurement is the first candidate in the new
// period. Saving it immediately avoids briefly showing zero before the next
// live frame restores the same weight as the record.
const currentWeight = connected && latestFrame ? roundedWeight(latestFrame.totalKg) : 0;
store.recordKg = currentWeight;
store.recordedAt = currentWeight > 0 ? Date.now() : null;
persistStore();
if (latestFrame) {
latestFrame = {
...latestFrame,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
};
publishLatestFrame();
}
events.emit('change', { state: getState() });
sendRawAlert('record-reset');
}
function updateStatus(nextStatus, nextDetail) {
const normalizedStatus = String(nextStatus || 'unknown');
const normalizedDetail = String(nextDetail || '');
if (status === normalizedStatus && detail === normalizedDetail) return;
status = normalizedStatus;
detail = normalizedDetail;
events.emit('change', { state: getState() });
}
function publishCalibrationState() {
// Calibration progress belongs in the ordinary session payload because it
// changes only once per second for ten seconds. Live 20 Hz weights remain in
// their dedicated room and never trigger a full-session broadcast.
events.emit('change', { state: getState() });
}
function clearZeroTimer() {
if (!zeroTimer) return;
clearInterval(zeroTimer);
zeroTimer = null;
}
function failZeroCalibration(error, { alert = true } = {}) {
clearZeroTimer();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: String(error || 'Calibration failed'),
};
publishCalibrationState();
if (alert) sendRawAlert('zero-failed', zeroProgress.error);
}
function finishZeroCalibration() {
clearZeroTimer();
// A single average could hide movement that returns to its starting point.
// Sum every corner's complete ten-second range before accepting the result so
// distributed movement cannot hide below four independent thresholds. Retain
// three decimals so averaging ten centi-kilogram samples does not throw away
// useful sub-centi-kilogram precision in the persisted baseline.
const combinedRange = CORNER_KEYS.reduce((totalRange, key) => {
const values = zeroSamples.map((sample) => sample[key]);
return totalRange + Math.max(...values) - Math.min(...values);
}, 0);
if (combinedRange > ZERO_MAX_COMBINED_RANGE_KG) {
failZeroCalibration('Load moved during the ten-second calibration.');
return;
}
store.zeroCorners = Object.fromEntries(CORNER_KEYS.map((key) => {
const average = zeroSamples.reduce((sum, sample) => sum + sample[key], 0) /
zeroSamples.length;
return [key, Math.round(average * 1000) / 1000];
}));
store.zeroedAt = Date.now();
// A new zero changes the meaning of every adjusted weight, so an old record
// cannot be compared with measurements under the new baseline.
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: ZERO_SAMPLE_COUNT,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
publishCalibrationState();
sendRawAlert('zeroed');
}
function takeZeroSample() {
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
failZeroCalibration('Live Balance Board data stopped during calibration.');
return;
}
zeroSamples.push({ ...latestRawCorners });
zeroProgress = {
active: true,
samplesCollected: zeroSamples.length,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
publishCalibrationState();
if (zeroSamples.length >= ZERO_SAMPLE_COUNT) finishZeroCalibration();
}
function startZeroCalibration() {
if (zeroProgress.active) throw new Error('Balance Board zero calibration is already running');
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
throw new Error('The Balance Board must be connected and sending weight data');
}
zeroSamples = [];
zeroProgress = {
active: true,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
publishCalibrationState();
sendRawAlert('zeroing');
// Delaying the first sample by one interval makes this a real ten-second
// calibration rather than ten rapid reads followed by nine seconds of UI.
zeroTimer = setInterval(takeZeroSample, ZERO_SAMPLE_INTERVAL_MS);
}
function processFrame(message = {}) {
const rawCorners = cornerWeightsKg(message.corners);
latestRawCorners = rawCorners;
latestRawFrameAt = Date.now();
if (Number.isFinite(Number(message.batteryPercent))) {
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
}
connected = true;
updateStatus('connected', 'Live weight is updating.');
const adjustedCorners = subtractZero(rawCorners);
const totalKg = totalCornerWeight(adjustedCorners);
if (totalKg > store.recordKg) {
// Store only adjusted weight so the displayed record uses the same admin
// zero baseline as the live total and all four corner readings.
store.recordKg = totalKg;
store.recordedAt = Date.now();
scheduleRecordPersistence();
}
latestFrame = {
totalKg,
corners: adjustedCorners,
batteryPercent,
recordKg: store.recordKg,
recordedAt: store.recordedAt,
};
publishLatestFrame();
}
function handleWorkerMessage(message = {}) {
if (message.type === 'frame') {
processFrame(message);
return;
}
if (message.type === 'paired') {
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
if (address && address !== store.address) {
store.address = address;
// A zero baseline belongs to one physical board and whatever permanent
// platform/load was present when an admin calibrated it. Never carry that
// baseline across commissioning a different Bluetooth identity.
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
}
hardware?.setAddress(address);
sendRawAlert('paired');
updateStatus('connecting', 'Paired. Connecting to the board now.');
return;
}
if (message.type !== 'status') return;
const workerState = String(message.state || 'unknown');
sendStatusAlert(workerState, message.error || '');
if (workerState === 'commissioning') {
updateStatus('starting', 'Starting Bluetooth discovery.');
} else if (workerState === 'discovering') {
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
} else if (workerState === 'pairing') {
updateStatus('pairing', 'Board found. Pairing now.');
} else if (workerState === 'connected') {
connected = true;
updateStatus('connected', 'Connected. Waiting for live weight data.');
} else if (workerState === 'link-detected') {
connected = false;
// The native bridge can now distinguish which half of the board's HID
// connection reached the server. Preserve that diagnostic until both
// channels arrive; the generic text remains for the outbound Sync flow.
updateStatus('connecting', message.error || 'Board responded. Reading its sensor calibration.');
} else if (workerState === 'connection-failed') {
connected = false;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
} else if (workerState === 'sleeping') {
connected = false;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Board slept during calibration.');
updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
} else if (workerState === 'waiting') {
connected = false;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
} else if (workerState === 'error') {
connected = false;
latestRawCorners = null;
latestRawFrameAt = 0;
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
updateStatus('error', message.error || 'The Balance Board worker stopped.');
}
}
io.on('connection', (socket) => {
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(FRAME_ROOM);
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
cb({ success: true });
});
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
}
try {
startZeroCalibration();
cb({ success: true });
} catch (err) {
cb({ error: err.message || 'Failed to start Balance Board zero calibration' });
}
});
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
}
try {
resetWeightRecord();
cb({ success: true });
} catch (err) {
logger.error('Failed to reset Balance Board weight record', err);
cb({ error: err.message || 'Failed to reset the Balance Board weight record' });
}
});
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
if (!isAdmin(socket)) {
cb({ error: 'Admin access required' });
return;
}
if (unpairing) {
cb({ error: 'The Balance Board is already being unpaired' });
return;
}
unpairing = true;
const address = store.address;
let bluetoothWarning = '';
try {
if (address) {
try {
// A complete forget removes both sources of remembered identity. If
// only the JSON address or only the BlueZ bond were removed, the next
// red-Sync attempt could inherit half of the previous relationship.
await execFileAsync('bluetoothctl', ['remove', address], { timeout: 10000 });
} catch (err) {
bluetoothWarning = String(
err?.stderr || err?.message || 'BlueZ did not remove the bond',
).trim();
logger.warn('Balance Board BlueZ bond removal failed', bluetoothWarning);
}
}
store.address = '';
store.zeroCorners = emptyZeroCorners();
store.zeroedAt = null;
clearRecordPersistTimer();
store.recordKg = 0;
store.recordedAt = null;
persistStore();
clearZeroTimer();
zeroSamples = [];
zeroProgress = {
active: false,
samplesCollected: 0,
totalSamples: ZERO_SAMPLE_COUNT,
error: '',
};
connected = false;
batteryPercent = null;
latestFrame = null;
latestRawCorners = null;
latestRawFrameAt = 0;
previousWorkerState = '';
hardware?.setAddress('');
hardware?.restart();
updateStatus('starting', 'Starting Bluetooth discovery.');
sendRawAlert('unpaired');
cb({ success: true, warning: bluetoothWarning || null });
} catch (err) {
logger.error('Failed to unpair Balance Board', err);
cb({ error: err.message || 'Failed to unpair the Balance Board' });
} finally {
unpairing = false;
}
});
});
if (enabled) {
hardware = createBalanceBoardHardware({
logger,
address: store.address,
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
});
hardware.events.on('message', handleWorkerMessage);
hardware.start();
} else {
logger.info('Balance Board disabled by config');
}
function installShutdownHooks() {
const shutdown = () => {
clearZeroTimer();
// A record may still be inside the short debounce window when the process
// receives a normal shutdown signal. Flush that newest maximum before the
// hardware worker stops so a clean restart cannot lose it.
if (recordPersistTimer) {
clearRecordPersistTimer();
persistStore();
}
hardware?.stop();
};
process.once('exit', shutdown);
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
}
installShutdownHooks();
module.exports = {
getState,
balanceBoardEvents: events,
};
@@ -0,0 +1,22 @@
CXX ?= g++
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
# profile: current BlueZ requests medium link security for a bonded board, and
# the original Balance Board rejects that negotiation before an input device is
# created.
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
LDLIBS += -lwiiuse -lbluetooth -pthread
TARGET := balance_board_worker
SRC := balance_board_worker.cpp
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(SRC)
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
clean:
rm -f $(TARGET)
File diff suppressed because it is too large Load Diff
+20 -15
View File
@@ -8,7 +8,7 @@ const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders'); const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
const { broadcastMessage, broadcastTyping } = require('./broadcast'); const { broadcastMessage, broadcastTyping } = require('./broadcast');
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications'); const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
const { runChatTextCommand } = require('./textCommands'); const { isTextCommand, runChatTextCommand } = require('./textCommands');
function createHandlers({ sendSystemMessage }) { function createHandlers({ sendSystemMessage }) {
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) { async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
@@ -53,21 +53,26 @@ function createHandlers({ sendSystemMessage }) {
maybeSendAccessNotice(message, sendSystemMessage); maybeSendAccessNotice(message, sendSystemMessage);
maybeSpeak(socket, message, ttsOptions); maybeSpeak(socket, message, ttsOptions);
try { const command = isTextCommand(clean);
// Commands sent from site chat should still be visible as normal chat // Chat delivery is complete once validation, broadcast, and local side
// messages. Running the command after broadcast preserves the user-visible // effects above have succeeded. A command may wait on Home Assistant,
// transcript while keeping permissions and command execution entirely on // hardware, replay preparation, or an external transport, so tying the
// the server. // socket acknowledgement to command completion leaves the browser's send
const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage }); // promise pending and makes its input state appear stuck. Acknowledge now;
cb({ success: true, command: ranCommand }); // command replies continue through the normal Rover bot message stream.
return; cb({ success: true, command });
} catch (err) {
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
return;
}
cb({ success: true }); if (command) {
// Deliberately do not await this promise. runChatTextCommand already turns
// ordinary command failures into visible bot messages; this final catch
// protects the service from an unexpected setup/programming failure and
// cannot attempt a second acknowledgement after the UI has moved on.
void runChatTextCommand({ text: clean, socket, sendSystemMessage }).catch((err) => {
logger.warn('Chat command failed after acknowledgement', { socket: socket?.id, error: err.message });
sendSystemMessage(`Command failed: ${err.message || 'unknown error'}`, { nickname: 'Rover bot', bot: true });
});
}
return;
} }
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) { function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
+23 -20
View File
@@ -10,6 +10,9 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { isFeatureEnabled } = require('../../helpers/features');
const { const {
listVerifiedUsers, listVerifiedUsers,
removeVerifiedUser, removeVerifiedUser,
@@ -20,20 +23,21 @@ const {
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../discordBotService/commands'); const { createCommandHandlers } = require('../operatorCommandService');
const { parseCommandText } = require('../operatorCommandService/config');
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
const { commandReplyToText } = require('./commandResultFormatter'); const { commandReplyToText } = require('./commandResultFormatter');
const { const {
buildReplayJobId, buildReplayJobId,
buildReplayTitle, buildReplayTitle,
createReplaySourceResolver, createReplaySourceResolver,
} = require('../discordBotService/replayWorkflow'); } = require('../replayDeliveryService/workflow');
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
function isTextCommand(text) { function isTextCommand(text) {
const clean = String(text || '').trim(); return parseCommandText(text, config).matched;
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
} }
function sanitizeMentions(text) { function sanitizeMentions(text) {
@@ -70,12 +74,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
return; return;
} }
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
await message.reply({ content: 'Replay denied: replay channel is not configured.' });
return;
}
const resolved = sourceResolver.resolve(query); const resolved = sourceResolver.resolve(query);
if (resolved?.error) { if (resolved?.error) {
await message.reply({ content: resolved.error }); await message.reply({ content: resolved.error });
@@ -99,7 +97,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
type: 'replay.requested', type: 'replay.requested',
payload: { payload: {
jobId, jobId,
channelId,
requester, requester,
title: '', title: '',
includeSidebar: true, includeSidebar: true,
@@ -113,18 +110,18 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
}; };
} }
function createChatCommandMessage({ socket, text, sendSystemMessage }) { function createChatCommandRequest({ socket, text, sendSystemMessage }) {
const nickname = buildRequesterLabel(socket); const nickname = buildRequesterLabel(socket);
return { return {
content: String(text || '').trim(), content: String(text || '').trim(),
author: { actor: {
bot: false, bot: false,
id: socket.id, id: socket.id,
username: nickname, label: nickname,
}, isAdmin: isAdmin(socket),
member: { isLockdownAdmin: isLockdownAdmin(socket),
nickname,
}, },
transport: 'web-chat',
reply: async (payload) => { reply: async (payload) => {
const response = sanitizeMentions(commandReplyToText(payload)); const response = sanitizeMentions(commandReplyToText(payload));
if (!response) return null; if (!response) return null;
@@ -139,8 +136,8 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// keeps ordinary chatService initialization from changing the service boot // keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline. // order, while still letting `rs replay` use the existing replay pipeline.
const replayApi = require('../replayEngineV2'); const replayApi = require('../replayEngineV2');
const message = createChatCommandMessage({ socket, text, sendSystemMessage }); const message = createChatCommandRequest({ socket, text, sendSystemMessage });
const commands = createCommandHandlers({ const commandDependencies = {
logger: null, logger: null,
client: null, client: null,
io, io,
@@ -168,6 +165,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// lights lock/unlock` from becoming transport-specific, and it preserves // lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers. // the existing session update path for all connected browsers.
homeAssistantService, homeAssistantService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig: () => null, getGuildConfig: () => null,
setGuildConfig: () => null, setGuildConfig: () => null,
removeGuildConfig: () => null, removeGuildConfig: () => null,
@@ -183,9 +183,12 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig, discordConfig,
siteUrl: String(discordConfig.siteUrl || ''),
config, config,
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi), createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
}); };
commandDependencies.transportHandlers = createWebTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
// Let the shared router perform normal command permission checks. Site chat // Let the shared router perform normal command permission checks. Site chat
// has already broadcast the user's command text, so command replies become a // has already broadcast the user's command text, so command replies become a
@@ -106,6 +106,28 @@ function handleAck(msg) {
}); });
} }
function issueUpdateToAllRovers() {
const updated = [];
const failed = [];
roverManager.rovers.forEach((record) => {
if (!record?.ws) return;
const roverId = String(record.id);
try {
// Use the same narrow update payload as the per-rover admin action. The
// browser only asks for "update all"; the Pi still owns the privileged
// pull/install/reboot sequence through its fixed self-update helper.
issueCommand(roverId, { type: 'update', update: {} });
updated.push(roverId);
} catch (err) {
failed.push({ roverId, error: err.message });
}
});
return { updated, failed };
}
function getRecentDriveActivity(windowMs, options = {}) { function getRecentDriveActivity(windowMs, options = {}) {
const now = Date.now(); const now = Date.now();
const results = []; const results = [];
@@ -288,6 +310,26 @@ io.on('connection', (socket) => {
socket.on('command', handleCommand); socket.on('command', handleCommand);
socket.on('command:issue', handleCommand); socket.on('command:issue', handleCommand);
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const result = issueUpdateToAllRovers();
logger.warn('Admin requested update for all online rovers', {
socketId: socket.id,
updated: result.updated,
failed: result.failed,
});
reply(result);
} catch (err) {
logger.warn('Update-all rovers rejected', socket.id, err.message);
reply({ error: err.message });
}
});
}); });
homeAssistantService.homeAssistantEvents.on('update', () => { homeAssistantService.homeAssistantEvents.on('update', () => {
@@ -0,0 +1,39 @@
// Discord Command Adapter
// Purpose: Supplies Discord-specific renderers and extension commands to the operator command service.
// Scope: Keeps Discord embeds, attachments, guild permissions, and bridge context outside the shared command core.
const { createStatusCommand } = require('./commands/status');
const { createReplayCommand } = require('./commands/replay');
const { createBridgeCommand } = require('./commands/bridge');
const { createTimeStatusCommand } = require('./commands/timeStatus');
function createDiscordTransportHandlers(deps) {
const status = createStatusCommand(deps);
const replay = createReplayCommand(deps);
const bridge = createBridgeCommand(deps);
const timeStatus = createTimeStatusCommand(deps);
return {
status: (request, query) => status(request.context.discordMessage, query),
replay: (request, query) => replay(request.context.discordMessage, query),
bridge: (request, tokens) => bridge(request.context.discordMessage, tokens),
timeStatus: (request) => timeStatus(request.context.discordMessage),
};
}
function createDiscordCommandRequest(message, { isAdminUser, isLockdownAdminUser }) {
const id = message.author?.id || null;
return {
content: String(message.content || ''),
transport: 'discord',
actor: {
id,
label: message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord',
bot: Boolean(message.author?.bot),
isAdmin: isAdminUser(id),
isLockdownAdmin: isLockdownAdminUser(id),
},
reply: (payload) => message.reply(payload),
context: { discordMessage: message },
};
}
module.exports = { createDiscordTransportHandlers, createDiscordCommandRequest };
@@ -2,11 +2,12 @@
// Purpose: Handles chat bridge configuration/status commands per guild. // Purpose: Handles chat bridge configuration/status commands per guild.
// Scope: Manages bridge channel, mode, and webhook provisioning. // Scope: Manages bridge channel, mode, and webhook provisioning.
const { PermissionsBitField } = require('discord.js'); const { PermissionsBitField } = require('discord.js');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, discordConfig }) { function createBridgeCommand({ getGuildConfig, setGuildConfig, removeGuildConfig, normalizeMode, VALID_MODES, isAdminUser, config }) {
// Error text should name the active prefix because bridge setup is one of the // Error text should name the active prefix because bridge setup is one of the
// first commands an admin runs when a bot instance joins a shared Discord. // first commands an admin runs when a bot instance joins a shared Discord.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs'; const { prefix: commandPrefix } = getCommandConfig(config);
function canManageBridge(message) { function canManageBridge(message) {
if (isAdminUser(message.author.id)) return true; if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false; if (!message.guild || !message.member) return false;
@@ -1,32 +0,0 @@
// Discord Help Command
// Purpose: Provides help text for rover bot Discord commands.
// Scope: Returns usage text with the configured command names for this bot instance.
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts' } = {}) {
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
return [
'**Rover Bot Commands**',
`\`${prefix} help\` — show this help`,
`\`${prefix} status [rover]\` — show rover status; rover names can be fuzzy`,
`\`${prefix} replay [sources]\` — send instant replay; source names can be fuzzy`,
`\`${prefix} bridge\` — show chat bridge status for this server`,
`\`${prefix} bridge here <global|private>\` — set chat bridge to this channel`,
`\`${prefix} bridge mode <global|private>\` — change chat bridge mode`,
`\`${prefix} bridge off\` — disable chat bridge for this server`,
`\`${prefix} lights <status|lock|unlock>\` — show or change room light lock state`,
`\`${prefix} kick <user> [reason]\` — remove a user from their current rover; use \`user | reason\` for multi-word names`,
`\`${prefix} lock <rover>\` — lock a rover; rover names can be fuzzy`,
`\`${prefix} unlock <rover>\` — unlock a rover; rover names can be fuzzy`,
`\`${prefix} mode <open|turns|admin|lockdown>\` — change server mode`,
`\`${prefix} reason [text|clear]\` — show or set admin mode reason`,
`\`${prefix} goal [text|clear]\` — show or set global objective`,
`\`${prefix} verify list\` — list verified users (lockdown admins)`,
`\`${prefix} verify remove <cookieUserId|nickname>\` — remove verified user; nicknames can be fuzzy or multi-word (lockdown admins)`,
`\`${prefix} deter list\` — list deterred users (lockdown admins)`,
`\`${prefix} deter ban <cookieUserId|nickname|ip>\` — deter a user; nicknames can be fuzzy or multi-word (lockdown admins)`,
`\`${prefix} deter unban <id|cookieUserId|nickname|ip>\` — remove deterrence; nicknames can be fuzzy or multi-word (lockdown admins)`,
timeCommand ? `\`${timeCommand}\` — show time status` : '',
].filter(Boolean).join('\n');
}
module.exports = { formatHelp };
@@ -1,141 +0,0 @@
// Discord Commands Router
// Purpose: Routes incoming Discord command messages to one-file-per-command handlers.
// Scope: Central command dispatcher and permission gate orchestration.
const { formatHelp } = require('./help');
const { createStatusCommand } = require('./status');
const { createReplayCommand } = require('./replay');
const { createLockCommand } = require('./lock');
const { createModeCommand } = require('./mode');
const { createReasonCommand } = require('./reason');
const { createGoalCommand } = require('./goal');
const { createVerifyCommand } = require('./verify');
const { createDeterCommand } = require('./deter');
const { createBridgeCommand } = require('./bridge');
const { createTimeStatusCommand } = require('./timeStatus');
const { createLightsCommand } = require('./lights');
const { createKickCommand } = require('./kick');
function createCommandHandlers(deps) {
const {
getMode,
MODES,
isAdminUser,
isLockdownAdminUser,
} = deps;
// Each running rover server can bring its own Discord bot into the same
// guild, so the primary command prefix must come from config instead of
// being hard-coded globally. The fallback preserves existing installs.
const commandPrefix = String(deps.discordConfig?.commandPrefix || 'rs').trim() || 'rs';
// The legacy time command is a bare word rather than a prefixed command. It
// therefore needs its own configurable value, and `null` intentionally
// disables it so multiple bots do not all answer `ts` in the same channel.
const timeStatusCommand = deps.discordConfig?.timeStatusCommand === null
? ''
: String(deps.discordConfig?.timeStatusCommand || 'ts').trim();
// Lowercase cached copies avoid re-normalizing every message and keep command
// matching case-insensitive without changing the original configured text
// that is shown in help output.
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const handleStatusCommand = createStatusCommand(deps);
const handleReplayCommand = deps.createReplayTextCommand
? deps.createReplayTextCommand(deps)
: createReplayCommand(deps);
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleBridgeCommand = createBridgeCommand(deps);
const handleTimeStatusCommand = createTimeStatusCommand(deps);
const handleLightsCommand = createLightsCommand(deps);
const handleKickCommand = createKickCommand(deps);
function stripCommandPrefix(content) {
const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase();
if (!lower.startsWith(normalizedCommandPrefix)) return null;
const nextCharacter = trimmed.charAt(commandPrefix.length);
// Prefixes are matched as whole command tokens so an instance using `rs`
// still ignores ordinary words such as `rsvp`. This mirrors the old regex
// behavior while letting each Discord bot instance use its own prefix.
if (nextCharacter && !/\s/.test(nextCharacter)) return null;
return trimmed.slice(commandPrefix.length).trim();
}
async function handleCommand(message) {
if (message.author.bot) return;
const content = (message.content || '').trim();
const lower = content.toLowerCase();
// Commands are intentionally matched as whole prefixes. The previous
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
// like commands, which is especially bad now that web chat will run the
// same server-side dispatcher before broadcasting user text.
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand(message);
const commandBody = stripCommandPrefix(content);
if (commandBody === null) return;
const tokens = commandBody ? commandBody.split(/\s+/) : [];
const action = (tokens.shift() || '').toLowerCase();
const rest = tokens.join(' ').trim();
const isAdmin = isAdminUser(message.author.id);
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
const mode = getMode();
// Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Room
// light locking belongs here because it can force the physical room lights
// on and disables ordinary Home Assistant room controls for everyone else.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick']);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await message.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
switch (action) {
case '':
case 'status':
return handleStatusCommand(message, rest);
case 'help':
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
case 'replay':
return handleReplayCommand(message, tokens.join(' '));
case 'bridge':
return handleBridgeCommand(message, tokens);
case 'lights':
return handleLightsCommand(message, tokens);
case 'kick':
return handleKickCommand(message, rest);
case 'lock':
return handleLockCommand(message, rest, true);
case 'unlock':
return handleLockCommand(message, rest, false);
case 'mode':
return handleModeCommand(message, tokens);
case 'goal':
return handleGoalCommand(message, tokens);
case 'reason':
return handleReasonCommand(message, tokens);
case 'verify':
return handleVerifyCommand(message, tokens);
case 'deter':
return handleDeterCommand(message, tokens);
default:
return message.reply(formatHelp({ commandPrefix, timeStatusCommand }));
}
}
return { handleCommand };
}
module.exports = { createCommandHandlers };
@@ -3,6 +3,7 @@
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs. // Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
const { AttachmentBuilder } = require('discord.js'); const { AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io'); const io = require('../../../globals/io');
const { hostReplay } = require('../../replayMediaService');
const { const {
DEFAULT_ALLOWED_MENTIONS, DEFAULT_ALLOWED_MENTIONS,
buildReplayJobId, buildReplayJobId,
@@ -17,7 +18,7 @@ const {
buildAcceptedMessage, buildAcceptedMessage,
buildStatusMessage, buildStatusMessage,
normalizeUserError, normalizeUserError,
} = require('../replayWorkflow'); } = require('../../replayDeliveryService/workflow');
function createReplayCommand({ function createReplayCommand({
logger, logger,
@@ -32,6 +33,7 @@ function createReplayCommand({
getActiveDrivers, getActiveDrivers,
getNickname, getNickname,
rovers, rovers,
discordConfig,
}) { }) {
const sourceResolver = createReplaySourceResolver({ const sourceResolver = createReplaySourceResolver({
rovers, rovers,
@@ -80,18 +82,21 @@ function createReplayCommand({
}); });
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command'); const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
let builtReplay = null;
let deliveredMedia = null;
try { try {
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') }); jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} }
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({ builtReplay = await buildReplayVideo({
sources: job.sources, sources: job.sources,
title: job.title, title: job.title,
requester: job.requester, requester: job.requester,
includeSidebar: job.includeSidebar, includeSidebar: job.includeSidebar,
}); });
const { buffer, usedSources = job.sources, missingSources = [] } = builtReplay;
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') }); jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
if (progressMessage?.edit) { if (progressMessage?.edit) {
@@ -108,14 +113,36 @@ function createReplayCommand({
if (!uploadMessage) throw new Error('Discord upload did not return a message'); if (!uploadMessage) throw new Error('Discord upload did not return a message');
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage); const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job }); deliveredMedia = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL'); if (!deliveredMedia) throw new Error('Discord upload did not include a replay attachment URL');
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media }); jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media: deliveredMedia });
if (progressMessage?.edit) { if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} }
} catch (err) { } catch (err) {
if (deliveredMedia) {
logger?.warn?.('Replay uploaded but Discord progress message could not be finalized', { jobId: job.id, error: err.message });
return;
}
// A completed video should never be discarded merely because the
// optional Discord upload failed. Host that exact buffer locally and
// publish the same ready event consumed by existing clients.
if (builtReplay?.buffer && !deliveredMedia) {
try {
const media = await hostReplay({ buffer: builtReplay.buffer, job });
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
const siteUrl = String(discordConfig?.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
await progressMessage.reply({ content: `Replay hosted by the rover server: ${publicUrl}`, allowedMentions: DEFAULT_ALLOWED_MENTIONS });
return;
} catch (fallbackError) {
logger?.warn?.('Local replay fallback failed', { jobId: job.id, error: fallbackError.message });
}
}
const userMessage = normalizeUserError(err); const userMessage = normalizeUserError(err);
jobStatus.emit(job, 'failed', { message: userMessage }); jobStatus.emit(job, 'failed', { message: userMessage });
if (progressMessage?.edit) { if (progressMessage?.edit) {
@@ -3,7 +3,7 @@
// Scope: Builds and sends rover status embed for one rover or all visible rovers. // Scope: Builds and sends rover status embed for one rover or all visible rovers.
const { EmbedBuilder } = require('discord.js'); const { EmbedBuilder } = require('discord.js');
const { buildBatteryStatusEmbed } = require('../batteryEmbeds'); const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
const { resolveRoverSelector } = require('./resolvers'); const { resolveRoverSelector } = require('../../operatorCommandService/commands/resolvers');
function createStatusCommand({ rovers, roverManager }) { function createStatusCommand({ rovers, roverManager }) {
return async function handleStatusCommand(message, roverId) { return async function handleStatusCommand(message, roverId) {
+97 -22
View File
@@ -5,10 +5,13 @@ const {
Client, Client,
GatewayIntentBits, GatewayIntentBits,
Partials, Partials,
AttachmentBuilder,
} = require('discord.js'); } = require('discord.js');
const logger = require('../../globals/logger').child('discordBot'); const logger = require('../../globals/logger').child('discordBot');
const io = require('../../globals/io'); const io = require('../../globals/io');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager; const { getRoster, lockRover, rovers } = roverManager;
const { MODES, getMode, setMode } = require('../modeManager'); const { MODES, getMode, setMode } = require('../modeManager');
@@ -20,6 +23,8 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService'); const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService'); const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { const {
getGuildConfig, getGuildConfig,
listGuildConfigs, listGuildConfigs,
@@ -48,26 +53,32 @@ const {
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence'); const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO'); const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('./commands'); const { createCommandHandlers } = require('../operatorCommandService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations'); const { createIntegrations } = require('./integrations');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
const config = loadConfig(); const config = loadConfig();
const discordConfig = config.discord || {}; const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token); const enabled = isFeatureEnabled('discord');
// These normalized command names mirror the command router. Bridge-channel // These normalized command names mirror the command router. Bridge-channel
// command replies are mirrored into web chat, so this entrypoint needs to know // command replies are mirrored into web chat, so this entrypoint needs to know
// the configured command names before it wraps message.reply. // the configured command names before it wraps message.reply.
const commandPrefix = String(discordConfig.commandPrefix || 'rs').trim() || 'rs';
const timeStatusCommand = discordConfig.timeStatusCommand === null
? ''
: String(discordConfig.timeStatusCommand || 'ts').trim();
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean)); const adminIds = new Set((config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean));
const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean)); const lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
if (!enabled) { if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token'); logger.info('Discord feature disabled or missing required token');
return; return;
} }
@@ -123,7 +134,72 @@ const presence = createPresenceManager({
countReady, countReady,
}); });
const commands = createCommandHandlers({ const replayCaption = createReplayCaptionBuilder({
io,
rovers,
getActiveDrivers,
getNickname,
sanitizeMentions,
});
// Discord is the preferred replay host only while this optional feature is
// active. The core replay delivery service owns generation and automatically
// falls back to its local media store when any operation below fails.
if (discordConfig?.channels?.replay) {
registerPreferredDeliveryProvider({
async begin(job) {
const channelId = discordConfig.channels.replay;
const progressMessage = await channelIO.sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
if (!progressMessage) throw new Error('Discord replay progress message could not be sent');
const channel = await channelIO.fetchChannel(channelId);
return {
channelId,
progressMessage,
stopTyping: startDiscordTypingLoop(channel, logger, 'web replay delivery'),
};
},
async deliver({ job, context, buffer, usedSources = job.sources, missingSources = [] }) {
const progressMessage = context?.progressMessage;
try {
if (progressMessage?.edit) {
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: firstAttachmentFromMessage(uploadMessage), job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
if (progressMessage?.edit) {
// The attachment URL is already durable once Discord returns it. A
// cosmetic progress-edit failure must not trigger a duplicate local
// replay or replace the successful media payload sent to clients.
await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS }).catch((err) => {
logger.warn('Discord replay uploaded but progress message update failed', { jobId: job.id, error: err.message });
});
}
return media;
} catch (err) {
err.progressMessage = progressMessage;
throw err;
} finally {
if (context?.stopTyping) context.stopTyping();
}
},
async completeFallback({ context, media }) {
const siteUrl = String(discordConfig.siteUrl || '').replace(/\/$/, '');
const publicUrl = siteUrl ? `${siteUrl}${media.url}` : media.url;
if (context?.progressMessage?.reply) {
await context.progressMessage.reply({
content: `Replay hosted by the rover server: ${publicUrl}`,
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
}
},
});
}
const commandDependencies = {
logger, logger,
client, client,
io, io,
@@ -151,6 +227,9 @@ const commands = createCommandHandlers({
// service into the shared command router keeps Discord and mirrored web-chat // service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here. // command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService, homeAssistantService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig, getGuildConfig,
setGuildConfig, setGuildConfig,
removeGuildConfig, removeGuildConfig,
@@ -167,7 +246,9 @@ const commands = createCommandHandlers({
isLockdownAdminUser, isLockdownAdminUser,
discordConfig, discordConfig,
config, config,
}); };
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
const integrations = createIntegrations({ const integrations = createIntegrations({
logger, logger,
@@ -209,16 +290,9 @@ const commands = createCommandHandlers({
const integrationHandlers = integrations.register(); const integrationHandlers = integrations.register();
function isTextCommand(content) { function isTextCommand(content) {
const clean = String(content || '').trim(); // Both transports share this parser so command detection cannot drift from
const lower = clean.toLowerCase(); // the dispatcher when an installation changes its prefix.
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return true; return parseCommandText(content, config).matched;
if (!lower.startsWith(normalizedCommandPrefix)) return false;
const nextCharacter = clean.charAt(commandPrefix.length);
// Mirrored bridge commands must use the exact same whole-token prefix rule
// as the command router. If this check is looser than the router, normal
// bridge chat can be wrapped as a command reply even though no command runs.
return !nextCharacter || /\s/.test(nextCharacter);
} }
function isBridgeChannelMessage(message) { function isBridgeChannelMessage(message) {
@@ -263,7 +337,8 @@ function createBridgeMirroredCommandMessage(message) {
client.on('messageCreate', async (message) => { client.on('messageCreate', async (message) => {
try { try {
await integrationHandlers.handleBridgeInbound(message); await integrationHandlers.handleBridgeInbound(message);
await commands.handleCommand(createBridgeMirroredCommandMessage(message)); const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) { } catch (err) {
logger.warn('Error handling Discord message', err.message); logger.warn('Error handling Discord message', err.message);
} }
@@ -2,28 +2,12 @@
// Purpose: Handles event-bus announcements to Discord channels. // Purpose: Handles event-bus announcements to Discord channels.
// Scope: Processes supported event types and posts formatted messages/embeds. // Scope: Processes supported event types and posts formatted messages/embeds.
const { EmbedBuilder, AttachmentBuilder } = require('discord.js'); const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io');
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds'); const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayJob,
createJobStatusEmitter,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('../replayWorkflow');
function createBusEventHandler(deps) { function createBusEventHandler(deps) {
const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel, fetchChannel, buildReplayVideo, getActiveDrivers, getNickname, sanitizeMentions } = deps; const { logger, discordConfig, roverManager, rovers, schedulePresenceRotation, formatDuration, sendToChannel } = deps;
const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']); const ADMIN_ALERT_EVENT_TYPES = new Set(['rover.online', 'rover.offline', 'rover.dockGuard', 'battery.warn', 'battery.urgent', 'battery.docked', 'battery.undocked', 'battery.charging.start', 'battery.charging.stop', 'battery.locked', 'battery.unlocked']);
let skippedFirstModeAnnouncement = false; let skippedFirstModeAnnouncement = false;
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
function buildEmbed({ title, description, color, includeSiteUrl = true }) { function buildEmbed({ title, description, color, includeSiteUrl = true }) {
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3); const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
@@ -55,66 +39,6 @@ function createBusEventHandler(deps) {
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId); await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: payloadEmbeds, files: Array.isArray(files) ? files : undefined }, { parse: [], roles: pingRoleId ? [pingRoleId] : [] }, !pingRoleId);
} }
async function sendReplayToChannel(channelId, requester, sources = [], explicitTitle = '', includeSidebar = true, jobId = null, requestedBy = null) {
if (!channelId) throw new Error('Replay channel not configured');
const job = createReplayJob({
id: jobId,
requester,
source: 'web',
title: explicitTitle,
sources,
includeSidebar,
requestedBy,
});
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
const progressMessage = await sendToChannel(channelId, buildAcceptedMessage(job), {}, DEFAULT_ALLOWED_MENTIONS);
const channel = await fetchChannel(channelId);
const stopTyping = startDiscordTypingLoop(channel, logger, 'web replay delivery');
try {
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
const { buffer, usedSources = job.sources, missingSources = [] } = await buildReplayVideo({
sources: job.sources,
title: job.title,
requester: job.requester,
includeSidebar: job.includeSidebar,
});
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await sendToChannel(channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
if (!uploadMessage) throw new Error('Discord upload did not return a message');
const uploadedAttachment = firstAttachmentFromMessage(uploadMessage);
const media = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
if (!media) throw new Error('Discord upload did not include a replay attachment URL');
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
if (progressMessage?.edit) await progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
} catch (err) {
const message = normalizeUserError(err);
jobStatus.emit(job, 'failed', { message });
if (progressMessage?.edit) await progressMessage.edit({ content: sanitizeMentions(message), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
throw err;
} finally {
stopTyping();
}
}
function handleReplayRequested(event) {
const payload = event?.payload || {};
sendReplayToChannel(
payload?.channelId,
payload?.requester,
payload?.sources || [],
payload?.title || '',
payload?.includeSidebar !== false,
payload?.jobId || null,
payload?.requestedBy || null,
).catch((err) => {
logger.warn('Replay send failed', { error: err.message });
});
}
function handleBusEvent(event) { function handleBusEvent(event) {
const { type, payload } = event || {}; const { type, payload } = event || {};
const channels = discordConfig.channels || {}; const channels = discordConfig.channels || {};
@@ -200,9 +124,9 @@ function createBusEventHandler(deps) {
}); });
break; break;
} }
case 'replay.requested': // Replay requests are deliberately consumed by replayDeliveryService.
handleReplayRequested(event); // Discord registers only a preferred delivery provider, allowing the
break; // same request to fall back locally without a second event subscriber.
case 'buttonBox.discordStalkerPing': { case 'buttonBox.discordStalkerPing': {
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.'; const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
announce({ announce({
@@ -234,7 +158,7 @@ function createBusEventHandler(deps) {
} }
} }
return { handleBusEvent, handleReplayRequested }; return { handleBusEvent };
} }
module.exports = { createBusEventHandler }; module.exports = { createBusEventHandler };
@@ -4,7 +4,12 @@
const { app } = require('../../globals/http'); const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService'); const { renderIndexHtml, renderOgImage } = require('../embedService');
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => { /*
Every client-side BrowserRouter entry point must also be an explicit HTTP
entry point. Including /ptz here lets direct loads and browser refreshes
receive the same rendered index document as navigation from the driver page.
*/
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz'], async (req, res) => {
try { try {
const html = await renderIndexHtml(req); const html = await renderIndexHtml(req);
res.type('html').send(html); res.type('html').send(html);
@@ -35,11 +35,26 @@ function registerHomeAssistantHooks(deps) {
return true; return true;
} }
function isBlockedByRoomControlLock() {
/*
The lock is meant to keep normal users and automated room-control
surfaces from changing the preferred room-light policy. Admins are the
exception because they may need to correct a single lamp, verify a Home
Assistant integration, or make an operational adjustment while the
public controls remain locked.
This server-side bypass is the authoritative rule. The React UI also
enables admin controls for usability, but clients are not trusted to
enforce permissions.
*/
return isLightControlLocked() && !isAdmin(socket);
}
socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => { socket.on('homeAssistant:toggle', async ({ entityId } = {}, cb = () => {}) => {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -55,7 +70,7 @@ function registerHomeAssistantHooks(deps) {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -71,7 +86,7 @@ function registerHomeAssistantHooks(deps) {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -90,7 +105,7 @@ function registerHomeAssistantHooks(deps) {
if (!hasPermission()) { if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' }); return cb({ error: 'Insufficient permissions to control Home Assistant' });
} }
if (isLightControlLocked()) { if (isBlockedByRoomControlLock()) {
return cb({ error: 'Room controls are locked' }); return cb({ error: 'Room controls are locked' });
} }
try { try {
@@ -426,14 +426,26 @@ function createRuntimeEngine(deps) {
async function setLightsLockedOn(nextValue, options = {}) { async function setLightsLockedOn(nextValue, options = {}) {
const next = Boolean(nextValue); const next = Boolean(nextValue);
const targetState = options?.targetState === 'off' ? 'off' : 'on'; const targetState = options?.targetState === 'off' ? 'off' : 'on';
const forceApply = Boolean(options.forceApply);
const nextLockState = next ? targetState : null; const nextLockState = next ? targetState : null;
const changed = runtime.lightsLockState !== nextLockState; const changed = runtime.lightsLockState !== nextLockState;
runtime.lightsLockState = nextLockState; runtime.lightsLockState = nextLockState;
if (runtime.lightsLockState != null) { if (runtime.lightsLockState != null) {
if ((changed || forceApply) && enabled) { if (changed && enabled) {
const source = String(options?.source || 'homeAssistant:setLightsLockedOn'); const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
/*
A room-light lock is a policy boundary, not an ongoing reconciliation
loop. Entering locked-on or locked-off sets every configured room
control to the preferred state once so the room starts from the
requested condition. After that first transition, the server leaves
Home Assistant alone so out-of-band controls such as wall switches,
Home Assistant dashboards, or vendor apps can still adjust individual
lights without being periodically overwritten.
Older callers may still pass forceApply from the previous behavior.
It is intentionally ignored here because repeated lock requests must
not become repeated light commands.
*/
if (runtime.lightsLockState === 'on') { if (runtime.lightsLockState === 'on') {
// The lock-on path is intentionally stronger than a normal bulk // The lock-on path is intentionally stronger than a normal bulk
// turn_on. It makes actual light entities white while still turning // turn_on. It makes actual light entities white while still turning
@@ -151,7 +151,6 @@ async function handleTrigger(event = {}) {
if (action === LIGHTS_LOCK_TOGGLE_ACTION) { if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
const lockedOn = await toggleLightsLockedOn({ const lockedOn = await toggleLightsLockedOn({
source: 'ha-button:lightsLockToggle', source: 'ha-button:lightsLockToggle',
forceApply: true,
}); });
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS; const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
sendTtsToNonPrivateRovers(message); sendTtsToNonPrivateRovers(message);
+22
View File
@@ -73,6 +73,12 @@ function clearIdleTimer() {
function scheduleIdleTimer() { function scheduleIdleTimer() {
if (runtime.timer) return; if (runtime.timer) return;
if (runtime.idleActionsCompleted) {
logger.info('Idle timer not scheduled; idle actions already completed for this no-operator window', {
lastTriggeredAt: runtime.lastTriggeredAt,
});
return;
}
runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS; runtime.deadlineAt = Date.now() + IDLE_TIMEOUT_MS;
logger.info('Idle timer scheduled', { logger.info('Idle timer scheduled', {
timeoutMs: IDLE_TIMEOUT_MS, timeoutMs: IDLE_TIMEOUT_MS,
@@ -87,6 +93,13 @@ function scheduleIdleTimer() {
return; return;
} }
runtime.lastTriggeredAt = Date.now(); runtime.lastTriggeredAt = Date.now();
/*
Mark this idle window as handled before running the action pipeline. The
pipeline can take time and can call into services that emit their own
state changes; setting the guard first prevents any nested refresh from
scheduling a second timer for the same continuous no-operator period.
*/
runtime.idleActionsCompleted = true;
const results = await runIdleActions(); const results = await runIdleActions();
logger.info('Idle automation executed', { logger.info('Idle automation executed', {
idleMs: IDLE_TIMEOUT_MS, idleMs: IDLE_TIMEOUT_MS,
@@ -102,6 +115,15 @@ function refreshIdleState() {
logger.info('Idle state refresh', activity); logger.info('Idle state refresh', activity);
if (activity.totalActive > 0) { if (activity.totalActive > 0) {
clearIdleTimer(); clearIdleTimer();
if (runtime.idleActionsCompleted) {
logger.info('Idle action one-shot reset; operator is online again', activity);
}
/*
A user/admin coming online starts a new activity window. When the room
later becomes idle again, the cleanup pipeline should be allowed to run
once for that new idle period.
*/
runtime.idleActionsCompleted = false;
return; return;
} }
scheduleIdleTimer(); scheduleIdleTimer();
+1
View File
@@ -5,6 +5,7 @@ const runtime = {
timer: null, timer: null,
deadlineAt: null, deadlineAt: null,
lastTriggeredAt: null, lastTriggeredAt: null,
idleActionsCompleted: false,
}; };
module.exports = { module.exports = {
+14 -11
View File
@@ -7,7 +7,7 @@ const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features'); const { isFeatureEnabled } = require('../../helpers/features');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { const {
homeAssistantEvents, homeAssistantEvents,
getRawEntitySnapshot, getRawEntitySnapshot,
@@ -186,13 +186,20 @@ if (featureEnabled) {
homeAssistantEvents.on('status', emitUpdate); homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => { io.on('connection', (socket) => {
function assertFeatureAccess() {
const mode = getMode();
// Lift is a public activity feature in open and turns modes. Restricted
// access modes mirror the rest of the server: admin mode admits normal
// admins, while lockdown admits only the explicitly stronger lockdown
// role. Enforcing this in the owning service keeps UI buttons and text
// command behavior aligned instead of trusting individual callers.
if (mode === MODES.ADMIN && !isAdmin(socket)) throw new Error('Admin mode: admins only');
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) throw new Error('Server in lockdown');
}
socket.on('lift:up', async (_, cb = () => {}) => { socket.on('lift:up', async (_, cb = () => {}) => {
try { try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { assertFeatureAccess();
throw new Error('Server in lockdown');
}
// Lift movement is now a public activity feature. Lockdown still wins
// above because that mode is the global safety/admin gate for the room.
const resp = await moveUp(socket.id || 'socket'); const resp = await moveUp(socket.id || 'socket');
cb({ success: true, ...resp }); cb({ success: true, ...resp });
} catch (err) { } catch (err) {
@@ -202,11 +209,7 @@ if (featureEnabled) {
socket.on('lift:down', async (_, cb = () => {}) => { socket.on('lift:down', async (_, cb = () => {}) => {
try { try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { assertFeatureAccess();
throw new Error('Server in lockdown');
}
// Public access intentionally mirrors lift:up so both directions share
// the same policy and cannot drift into different permission behavior.
const resp = await moveDown(socket.id || 'socket'); const resp = await moveDown(socket.id || 'socket');
cb({ success: true, ...resp }); cb({ success: true, ...resp });
} catch (err) { } catch (err) {
+14 -18
View File
@@ -8,7 +8,7 @@ const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features'); const { isFeatureEnabled } = require('../../helpers/features');
const { isVerified } = require('../verificationService'); const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager'); const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService'); const { isAdmin, isLockdownAdmin } = require('../roleService');
const { const {
homeAssistantEvents, homeAssistantEvents,
getRawEntitySnapshot, getRawEntitySnapshot,
@@ -269,17 +269,19 @@ function hasVerifiedSockets() {
if (featureEnabled) { if (featureEnabled) {
io.on('connection', (socket) => { io.on('connection', (socket) => {
function assertLockdownAccess() { function assertFeatureAccess() {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { const mode = getMode();
throw new Error('Server in lockdown'); // Neato shares the same public-activity policy as lift: everyone may use
} // it in open/turns modes, admin mode requires an admin, and lockdown
// requires a lockdown admin. This service-level gate protects every socket
// action even if a future client bypasses the current UI presentation.
if (mode === MODES.ADMIN && !isAdmin(socket)) throw new Error('Admin mode: admins only');
if (mode === MODES.LOCKDOWN && !isLockdownAdmin(socket)) throw new Error('Server in lockdown');
} }
socket.on('neato:start', async (_, cb = () => {}) => { socket.on('neato:start', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertFeatureAccess();
// Neato commands are public activity features. The lockdown check above
// remains the room-wide safety/admin gate when the server is restricted.
await startCleaning(); await startCleaning();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -289,8 +291,7 @@ if (featureEnabled) {
socket.on('neato:sendHome', async (_, cb = () => {}) => { socket.on('neato:sendHome', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertFeatureAccess();
// Keep send-home public for consistency with the rest of the Neato card.
await sendHome(); await sendHome();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -300,8 +301,7 @@ if (featureEnabled) {
socket.on('neato:locate', async (_, cb = () => {}) => { socket.on('neato:locate', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertFeatureAccess();
// Locate is a public activity action; lockdown still blocks it above.
await locateRobot(); await locateRobot();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -311,9 +311,7 @@ if (featureEnabled) {
socket.on('neato:clearErrors', async (_, cb = () => {}) => { socket.on('neato:clearErrors', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertFeatureAccess();
// Error clearing is grouped with the public Neato controls so the UI does
// not show a button that only some public users can actually run.
await clearErrors(); await clearErrors();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -323,9 +321,7 @@ if (featureEnabled) {
socket.on('neato:powerCycle', async (_, cb = () => {}) => { socket.on('neato:powerCycle', async (_, cb = () => {}) => {
try { try {
assertLockdownAccess(); assertFeatureAccess();
// Power cycle follows the same public policy as the rest of the card;
// operational safety remains controlled by lockdown mode.
await powerCycle(); await powerCycle();
cb({ success: true }); cb({ success: true });
} catch (err) { } catch (err) {
@@ -1,16 +1,15 @@
// Discord Deter Command // Operator Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins. // Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands. // Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers'); const { mask, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) { function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) {
// Moderation errors often get copied into Discord chat, so they should show // Moderation usage errors use the same core prefix shown by organized help.
// the configured bot prefix instead of the legacy default when several bots const { prefix: commandPrefix } = getCommandConfig(config);
// are present in the same server.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
return async function handleDeterCommand(message, tokens) { return async function handleDeterCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) { if (!message.actor?.isLockdownAdmin) {
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
@@ -33,7 +32,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
// full remaining text is now always the selector, which lets lockdown // full remaining text is now always the selector, which lets lockdown
// admins deter multi-word nicknames without quoting or delimiter rules. // admins deter multi-word nicknames without quoting or delimiter rules.
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector; const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
const deterred = deterUser(stableSelector, { actor: message.author?.id || null }); const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -45,7 +44,7 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
try { try {
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true }); const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } }); if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.author?.id || null); const removed = undeterUser(resolved.record.id || resolved.record.cookieUserId || selector, message.actor?.id || null);
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -1,7 +1,7 @@
// Discord Goal Command // Operator Goal Command
// Purpose: Handles global objective view/update/clear operations. // Purpose: Handles global objective view/update/clear operations.
// Scope: Allows read by all and write by admins. // Scope: Allows read by all and write by admins.
function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, isAdminUser, sanitizeMentions }) { function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobalObjective, sanitizeMentions }) {
return async function handleGoalCommand(message, tokens) { return async function handleGoalCommand(message, tokens) {
const query = tokens.join(' ').trim(); const query = tokens.join(' ').trim();
const lower = query.toLowerCase(); const lower = query.toLowerCase();
@@ -10,16 +10,16 @@ function createGoalCommand({ getGlobalObjective, setGlobalObjective, clearGlobal
await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: goal?.text ? `Global objective: ${sanitizeMentions(goal.text)}` : 'No global objective set.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
if (!isAdminUser(message.author.id)) { if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Only admins can update the global objective.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
try { try {
if (lower === 'clear') { if (lower === 'clear') {
clearGlobalObjective({ by: message.author?.id || null }); clearGlobalObjective({ by: message.actor?.id || null });
await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Global objective cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else { } else {
setGlobalObjective(query, { by: message.author?.id || null }); setGlobalObjective(query, { by: message.actor?.id || null });
await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: sanitizeMentions(`Global objective set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
} }
} catch (err) { } catch (err) {
@@ -1,7 +1,8 @@
// Discord Kick Command // Operator Kick Command
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state. // Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment. // Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
const { getCommandConfig } = require('../../operatorCommandService/config');
const DEFAULT_KICK_REASON = 'Removed from rover by admin.'; const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
@@ -97,11 +98,11 @@ function resolveKickTarget(selector, candidates, commandPrefix = 'rs') {
return { target: first.item }; return { target: first.item };
} }
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, discordConfig }) { function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, config }) {
// The kick parser itself does not need the prefix, but its validation message // The kick parser itself does not need the prefix, but its validation message
// does. Keeping this local avoids passing display-only config through the // does. Keeping this local avoids passing display-only config through the
// lower-level fuzzy target resolver except when an error string is needed. // lower-level fuzzy target resolver except when an error string is needed.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs'; const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleKickCommand(message, rawText) { return async function handleKickCommand(message, rawText) {
const { selector, reason } = splitSelectorAndReason(rawText); const { selector, reason } = splitSelectorAndReason(rawText);
const assignmentService = require('../../assignmentService'); const assignmentService = require('../../assignmentService');
@@ -127,7 +128,7 @@ function createKickCommand({ io, roverManager, getNickname, sanitizeMentions, di
title: 'Removed by admin', title: 'Removed by admin',
message: removalReason, message: removalReason,
reasonCode: 'admin-kick', reasonCode: 'admin-kick',
actor: message.author?.id || null, actor: message.actor?.id || null,
}); });
await message.reply({ await message.reply({
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`), content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
@@ -0,0 +1,27 @@
// Lift Feature Command
// Purpose: Exposes lift state and movement through the shared text command route.
// Scope: Delegates interlocks, cooldowns, Home Assistant access, and runtime safety to liftService.
function describeState(state = {}) {
const position = state.position || 'unknown';
const connection = state.connected ? 'connected' : 'offline';
const activity = state.busy ? `moving ${state.target || ''}`.trim() : 'idle';
return `Lift: ${connection}; position ${position}; ${activity}.`;
}
function createLiftCommand({ liftService, sanitizeMentions }) {
return async function handleLiftCommand(message, tokens = []) {
const action = String(tokens.shift() || 'status').toLowerCase();
if (action === 'status') return message.reply({ content: describeState(liftService.getState()) });
try {
if (action === 'up') await liftService.moveUp(`command:${message.actor?.id || 'unknown'}`);
else if (action === 'down') await liftService.moveDown(`command:${message.actor?.id || 'unknown'}`);
else return message.reply({ content: 'Invalid lift command. Use `lift status`, `lift up`, or `lift down`.' });
return message.reply({ content: `Lift moving ${action}.` });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Lift command failed: ${err.message}`) });
}
};
}
module.exports = { createLiftCommand };
@@ -1,6 +1,8 @@
// Discord Lights Command // Operator Lights Command
// Purpose: Handles admin room-light lock policy commands from Discord and web chat. // Purpose: Handles admin room-light lock policy commands from Discord and web chat.
// Scope: Delegates all actual Home Assistant policy behavior to homeAssistantService. // Scope: Delegates all actual Home Assistant policy behavior to homeAssistantService.
const { getCommandConfig } = require('../../operatorCommandService/config');
function describeLightPolicy(lightPolicy = {}) { function describeLightPolicy(lightPolicy = {}) {
// The HA service exposes both the newer explicit lockState and the older // The HA service exposes both the newer explicit lockState and the older
// lockedOn boolean. Prefer lockState because it can distinguish locked-on // lockedOn boolean. Prefer lockState because it can distinguish locked-on
@@ -12,10 +14,10 @@ function describeLightPolicy(lightPolicy = {}) {
return 'Room lights are unlocked.'; return 'Room lights are unlocked.';
} }
function createLightsCommand({ homeAssistantService, sanitizeMentions, discordConfig }) { function createLightsCommand({ homeAssistantService, sanitizeMentions, config }) {
// The HA policy behavior is prefix-agnostic; this value is only used so // The HA policy behavior is prefix-agnostic; this value is only used so
// invalid-command guidance points admins at this bot instance's namespace. // invalid-command guidance points admins at this bot instance's namespace.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs'; const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleLightsCommand(message, tokens = []) { return async function handleLightsCommand(message, tokens = []) {
// Defaulting to status makes the bare lights command safe to type while // Defaulting to status makes the bare lights command safe to type while
// still exposing explicit mutating forms under the configured prefix. This // still exposing explicit mutating forms under the configured prefix. This
@@ -52,12 +54,11 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, discordCo
// The bot command intentionally calls the shared policy setter instead of // The bot command intentionally calls the shared policy setter instead of
// issuing direct Home Assistant entity commands. That keeps all secondary // issuing direct Home Assistant entity commands. That keeps all secondary
// behavior centralized: web UI controls become disabled through the // behavior centralized: web UI controls become disabled through the
// session lightPolicy update, lock-on still forces configured lights to // session lightPolicy update, entering lock-on still sets configured
// white where possible, and commandService sees the same update event that // lights to white where possible once, and commandService sees the same
// forces rover lasers off while the room is locked on. // update event that forces rover lasers off while the room is locked on.
await homeAssistantService.setLightsLockedOn(locked, { await homeAssistantService.setLightsLockedOn(locked, {
source: `bot-command:lights:${action}`, source: `bot-command:lights:${action}`,
forceApply: true,
}); });
await message.reply({ await message.reply({
@@ -1,12 +1,13 @@
// Discord Lock Command // Operator Lock Command
// Purpose: Handles lock and unlock operations for rover availability control. // Purpose: Handles lock and unlock operations for rover availability control.
// Scope: Applies lock state updates for a single rover ID. // Scope: Applies lock state updates for a single rover ID.
const { resolveRoverSelector } = require('./resolvers'); const { resolveRoverSelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createLockCommand({ lockRover, sanitizeMentions, rovers, discordConfig }) { function createLockCommand({ lockRover, sanitizeMentions, rovers, config }) {
// Only the user-facing example depends on the prefix. The actual lock logic // Only the user-facing example depends on the prefix. The actual lock logic
// still receives the already-parsed rover selector from the shared router. // still receives the already-parsed rover selector from the shared router.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs'; const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleLockCommand(message, roverId, locked) { return async function handleLockCommand(message, roverId, locked) {
if (!roverId) { if (!roverId) {
await message.reply({ content: `Specify a rover ID. Example: \`${commandPrefix} lock alpha\``, allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: `Specify a rover ID. Example: \`${commandPrefix} lock alpha\``, allowedMentions: { parse: [], repliedUser: false } });
@@ -21,7 +22,9 @@ function createLockCommand({ lockRover, sanitizeMentions, rovers, discordConfig
// Mutate by canonical id after fuzzy resolution. This avoids letting a // Mutate by canonical id after fuzzy resolution. This avoids letting a
// display-name typo create a new path through roverManager, and it also // display-name typo create a new path through roverManager, and it also
// makes the response name match the rover that was actually changed. // makes the response name match the rover that was actually changed.
lockRover(resolved.id, locked, { reason: 'discord' }); // Preserve the established Discord reason while allowing other adapters
// to identify themselves without pretending their request came from Discord.
lockRover(resolved.id, locked, { reason: message.transport === 'discord' ? 'discord' : 'web-chat' });
await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${resolved.label || resolved.id}.`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: sanitizeMentions(`Failed: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -1,7 +1,7 @@
// Discord Mode Command // Operator Mode Command
// Purpose: Handles mode updates from Discord admins through the configured bot prefix. // Purpose: Handles mode updates from authorized operators through the shared command prefix.
// Scope: Validates mode values and applies mode changes with optional reason text. // Scope: Validates mode values and applies mode changes with optional reason text.
function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser, sanitizeMentions }) { function createModeCommand({ MODES, setMode, setAdminReason, sanitizeMentions }) {
return async function handleModeCommand(message, tokens = []) { return async function handleModeCommand(message, tokens = []) {
const next = String(tokens.shift() || '').toLowerCase(); const next = String(tokens.shift() || '').toLowerCase();
const reasonText = tokens.join(' ').trim(); const reasonText = tokens.join(' ').trim();
@@ -10,9 +10,9 @@ function createModeCommand({ MODES, setMode, setAdminReason, isLockdownAdminUser
return; return;
} }
try { try {
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin'; const role = message.actor?.isLockdownAdmin ? 'lockdown' : 'admin';
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } }); setMode(next, { data: { role, user: { username: `${message.transport}:${message.actor?.label || 'unknown'}` } } });
if (reasonText) setAdminReason(reasonText, { by: message.author?.id || null }); if (reasonText) setAdminReason(reasonText, { by: message.actor?.id || null });
await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: sanitizeMentions(`Mode set to ${next}.`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: sanitizeMentions(`Failed to set mode: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -0,0 +1,35 @@
// Neato Feature Command
// Purpose: Exposes Neato state and supported actions through the shared text command route.
// Scope: Delegates device availability, Home Assistant calls, and operational errors to neatoService.
function describeState(state = {}) {
const telemetry = state.telemetry || {};
const connection = state.connected ? 'connected' : 'offline';
return `Neato: ${connection}; state ${telemetry.robotState || 'unknown'}; battery ${telemetry.batteryLevel ?? 'unknown'}%.`;
}
function createNeatoCommand({ neatoService, sanitizeMentions }) {
return async function handleNeatoCommand(message, tokens = []) {
const action = String(tokens.shift() || 'status').toLowerCase();
if (action === 'status') return message.reply({ content: describeState(neatoService.getState()) });
const actions = {
start: ['now cleaning', neatoService.startCleaning],
home: ['returning home', neatoService.sendHome],
locate: ['playing locate sound', neatoService.locateRobot],
'clear-errors': ['clearing errors', neatoService.clearErrors],
};
const selected = actions[action];
if (!selected) {
return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato locate`, or `neato clear-errors`.' });
}
try {
await selected[1]();
return message.reply({ content: `Neato is ${selected[0]}.` });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Neato command failed: ${err.message}`) });
}
};
}
module.exports = { createNeatoCommand };
@@ -1,7 +1,7 @@
// Discord Reason Command // Operator Reason Command
// Purpose: Handles admin-mode reason view/update/clear operations. // Purpose: Handles admin-mode reason view/update/clear operations.
// Scope: Allows read by all and write by admins. // Scope: Allows read by all and write by admins.
function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, isAdminUser, sanitizeMentions }) { function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason, sanitizeMentions }) {
return async function handleReasonCommand(message, tokens) { return async function handleReasonCommand(message, tokens) {
const query = tokens.join(' ').trim(); const query = tokens.join(' ').trim();
const lower = query.toLowerCase(); const lower = query.toLowerCase();
@@ -10,16 +10,16 @@ function createReasonCommand({ getAdminReason, setAdminReason, clearAdminReason,
await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: reason?.text ? `Admin mode reason: ${sanitizeMentions(reason.text)}` : 'No admin mode reason set.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
if (!isAdminUser(message.author.id)) { if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Only admins can update the admin mode reason.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
try { try {
if (lower === 'clear') { if (lower === 'clear') {
clearAdminReason({ by: message.author?.id || null }); clearAdminReason({ by: message.actor?.id || null });
await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Admin mode reason cleared.', allowedMentions: { parse: [], repliedUser: false } });
} else { } else {
setAdminReason(query, { by: message.author?.id || null }); setAdminReason(query, { by: message.actor?.id || null });
await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: sanitizeMentions(`Admin mode reason set: ${query}`), allowedMentions: { parse: [], repliedUser: false } });
} }
} catch (err) { } catch (err) {
@@ -1,4 +1,4 @@
// Discord Command Resolvers // Operator Command Resolvers
// Purpose: Provides shared selector parsing and fuzzy matching for command handlers. // Purpose: Provides shared selector parsing and fuzzy matching for command handlers.
// Scope: Keeps potentially destructive commands from each inventing their own lookup rules. // Scope: Keeps potentially destructive commands from each inventing their own lookup rules.
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
@@ -1,14 +1,14 @@
// Discord Verify Command // Operator Verify Command
// Purpose: Handles verified-user moderation commands for lockdown admins. // Purpose: Handles verified-user moderation commands for lockdown admins.
// Scope: Supports list and remove subcommands. // Scope: Supports list and remove subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers'); const { mask, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) { function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, sanitizeMentions, config }) {
// Verification usage text follows the configured prefix for the same reason // Usage text comes from the same core prefix that both transports parse.
// as the command router: each bot instance needs its own command namespace. const { prefix: commandPrefix } = getCommandConfig(config);
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
return async function handleVerifyCommand(message, tokens) { return async function handleVerifyCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) { if (!message.actor?.isLockdownAdmin) {
await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Only lockdown admins can manage verified users.', allowedMentions: { parse: [], repliedUser: false } });
return; return;
} }
@@ -29,7 +29,7 @@ function createVerifyCommand({ listVerifiedUsers, removeVerifiedUser, isLockdown
// The command resolver only turns a human-friendly or fuzzy nickname // The command resolver only turns a human-friendly or fuzzy nickname
// into the stable cookie id so the service does not need Discord/Web // into the stable cookie id so the service does not need Discord/Web
// command concerns baked into its storage API. // command concerns baked into its storage API.
const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.author?.id || null); const removed = removeVerifiedUser(resolved.record.userId || resolved.record.id || resolved.record.cookieUserId, message.actor?.id || null);
return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: `Removed verified user ${sanitizeMentions(removed.nickname || 'unknown')} (${mask(removed.cookieUserId)}).`, allowedMentions: { parse: [], repliedUser: false } });
} catch (err) { } catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } }); return message.reply({ content: sanitizeMentions(`Failed to remove verified user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
@@ -0,0 +1,45 @@
// Operator Command Configuration
// Purpose: Owns transport-neutral command names used by site chat and optional integrations.
// Scope: Prevents Discord configuration from defining whether core server commands can be parsed.
const { loadConfig } = require('../../helpers/configLoader');
function getCommandConfig(config = loadConfig()) {
const commandConfig = config.commands || {};
const prefix = String(commandConfig.prefix || 'rs').trim() || 'rs';
const timeStatusCommand = commandConfig.timeStatusCommand === null
? ''
: String(commandConfig.timeStatusCommand || 'ts').trim();
return { prefix, timeStatusCommand };
}
function parseCommandText(text, config = loadConfig()) {
const clean = String(text || '').trim();
const lower = clean.toLowerCase();
const { prefix, timeStatusCommand } = getCommandConfig(config);
const normalizedPrefix = prefix.toLowerCase();
const normalizedTimeStatus = timeStatusCommand.toLowerCase();
if (normalizedTimeStatus && lower === normalizedTimeStatus) {
return { matched: true, kind: 'time-status', body: '', action: 'time-status', tokens: [] };
}
if (!lower.startsWith(normalizedPrefix)) return { matched: false };
const nextCharacter = clean.charAt(prefix.length);
if (nextCharacter && !/\s/.test(nextCharacter)) return { matched: false };
const body = clean.slice(prefix.length).trim();
const tokens = body ? body.split(/\s+/) : [];
return {
matched: true,
kind: 'prefixed',
body,
action: String(tokens[0] || '').toLowerCase(),
tokens,
};
}
module.exports = {
getCommandConfig,
parseCommandText,
};
@@ -0,0 +1,46 @@
// Operator Command Help
// Purpose: Generates organized command help from one descriptive command catalogue.
// Scope: Keeps shared command discovery consistent while allowing Discord-only extensions to stay transport-specific.
const { CATEGORIES, buildCommandRegistry } = require('./registry');
function renderDetailed(name, entry, isFeatureEnabled) {
const details = [`**${name}**`, entry.summary];
if (entry.access) details.push(`Permission: ${entry.access}`);
if (entry.requiredFeature) details.push(`Required feature: ${entry.requiredFeature}`);
if (entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature)) details.push('Availability: unavailable on this server');
details.push('Usage:', ...entry.usage.map((usage) => `- \`${usage}\``));
return details.join('\n');
}
function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '', includeDiscord = true, isFeatureEnabled = () => true } = {}) {
const prefix = String(commandPrefix || 'rs').trim() || 'rs';
const timeCommand = timeStatusCommand ? String(timeStatusCommand).trim() : '';
const entries = buildCommandRegistry(prefix, timeCommand);
const normalizedTopic = String(topic || '').trim().toLowerCase();
if (entries[normalizedTopic] && (normalizedTopic !== 'bridge' || includeDiscord)) {
return renderDetailed(normalizedTopic, entries[normalizedTopic], isFeatureEnabled);
}
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
? [requestedCategory]
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
const output = ['**Rover Bot Commands**'];
for (const categoryName of categoryNames) {
if (categoryName === 'discord' && !includeDiscord) continue;
const category = CATEGORIES[categoryName];
output.push('', `**${category.title}**`);
for (const name of category.names) {
const entry = entries[name];
if (!entry?.usage?.length) continue;
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
output.push(`\`${entry.usage[0]}\`${entry.summary}${availability}`);
}
}
output.push('', `Use \`${prefix} help <command|category>\` for details.`);
return output.join('\n');
}
module.exports = { formatHelp };
@@ -0,0 +1,163 @@
// Operator Command Service
// Purpose: Routes transport-neutral operator command requests to registered server handlers.
// Scope: Owns shared parsing, authorization, feature gating, help, and execution without importing Discord.js.
const { formatHelp } = require('./help');
const { createLockCommand } = require('./commands/lock');
const { createModeCommand } = require('./commands/mode');
const { createReasonCommand } = require('./commands/reason');
const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createLightsCommand } = require('./commands/lights');
const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift');
const { createNeatoCommand } = require('./commands/neato');
const { getCommandConfig } = require('./config');
const { buildCommandRegistry } = require('./registry');
function createCommandHandlers(deps) {
const {
getMode,
MODES,
} = deps;
// The prefix belongs to the always-available command system so every
// transport parses the same namespace instead of maintaining local defaults.
const { prefix: commandPrefix, timeStatusCommand } = getCommandConfig(deps.config);
// The legacy time command is a bare word rather than a prefixed command. It
// therefore needs its own configurable value, and `null` intentionally
// disables it so multiple bots do not all answer `ts` in the same channel.
// Lowercase cached copies avoid re-normalizing every message and keep command
// matching case-insensitive without changing the original configured text
// that is shown in help output.
const normalizedCommandPrefix = commandPrefix.toLowerCase();
const normalizedTimeStatusCommand = timeStatusCommand.toLowerCase();
const registry = buildCommandRegistry(commandPrefix, timeStatusCommand);
// Status, time status, replay delivery, and transport extensions may have
// different presentation needs. Adapters inject those focused handlers while
// the core retains parsing, policy, and command discovery ownership.
const transportHandlers = deps.transportHandlers || {};
const handleStatusCommand = transportHandlers.status;
const handleReplayCommand = deps.createReplayTextCommand
? deps.createReplayTextCommand(deps)
: transportHandlers.replay;
const handleLockCommand = createLockCommand(deps);
const handleModeCommand = createModeCommand(deps);
const handleReasonCommand = createReasonCommand(deps);
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps);
const handleKickCommand = createKickCommand(deps);
const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps);
function stripCommandPrefix(content) {
const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase();
if (!lower.startsWith(normalizedCommandPrefix)) return null;
const nextCharacter = trimmed.charAt(commandPrefix.length);
// Prefixes are matched as whole command tokens so an instance using `rs`
// still ignores ordinary words such as `rsvp`. This mirrors the old regex
// behavior while letting each Discord bot instance use its own prefix.
if (nextCharacter && !/\s/.test(nextCharacter)) return null;
return trimmed.slice(commandPrefix.length).trim();
}
async function handleCommand(request) {
if (request.actor?.bot) return;
const content = (request.content || '').trim();
const lower = content.toLowerCase();
// Commands are intentionally matched as whole prefixes. The previous
// startsWith checks made ordinary messages such as "rsvp" or "tshirt" look
// like commands, which is especially bad now that web chat will run the
// same server-side dispatcher before broadcasting user text.
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return handleTimeStatusCommand?.(request);
const commandBody = stripCommandPrefix(content);
if (commandBody === null) return;
const tokens = commandBody ? commandBody.split(/\s+/) : [];
const action = (tokens.shift() || '').toLowerCase();
const rest = tokens.join(' ').trim();
const isAdmin = Boolean(request.actor?.isAdmin);
const isLockdownAdmin = Boolean(request.actor?.isLockdownAdmin);
const mode = getMode();
const commandDefinition = registry[action];
if (commandDefinition?.requiredFeature && !deps.isFeatureEnabled(commandDefinition.requiredFeature)) {
await request.reply({ content: `${commandDefinition.unavailableLabel || commandDefinition.requiredFeature} feature is not configured.` });
return;
}
// Actions in this set can change operational safety or access policy, so
// lockdown mode narrows them from normal admins to lockdown admins. Room
// light locking belongs here because it can force the physical room lights
// on and disables ordinary Home Assistant room controls for everyone else.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
// Feature commands are public activities while access is open or managed
// by turns. In admin mode they follow the same admin-only boundary as rover
// access, and lockdown continues to require the stricter lockdown role.
// Keeping this policy in the shared dispatcher makes web chat and Discord
// behave identically instead of each transport interpreting modes itself.
if (isAccessModeCommand && mode === MODES.ADMIN && !isAdmin) {
await request.reply({ content: 'Admin mode: only admins can run feature commands.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (!isAccessModeCommand && !isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
switch (action) {
case '':
case 'status':
return handleStatusCommand?.(request, rest);
case 'help':
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, topic: rest, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
case 'replay':
return handleReplayCommand?.(request, tokens.join(' '));
case 'bridge':
if (!handleBridgeCommand) return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: false, isFeatureEnabled: deps.isFeatureEnabled }));
return handleBridgeCommand(request, tokens);
case 'lights':
return handleLightsCommand(request, tokens);
case 'kick':
return handleKickCommand(request, rest);
case 'lift':
return handleLiftCommand(request, tokens);
case 'neato':
return handleNeatoCommand(request, tokens);
case 'lock':
return handleLockCommand(request, rest, true);
case 'unlock':
return handleLockCommand(request, rest, false);
case 'mode':
return handleModeCommand(request, tokens);
case 'goal':
return handleGoalCommand(request, tokens);
case 'reason':
return handleReasonCommand(request, tokens);
case 'verify':
return handleVerifyCommand(request, tokens);
case 'deter':
return handleDeterCommand(request, tokens);
default:
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
}
}
return { handleCommand };
}
module.exports = { createCommandHandlers };
@@ -0,0 +1,32 @@
// Operator Command Registry
// Purpose: Describes command categories, discovery text, permissions, and feature requirements in one place.
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
features: { title: 'Features', names: ['lift', 'neato'] },
discord: { title: 'Discord', names: ['bridge'] },
};
function buildCommandRegistry(prefix, timeCommand) {
return {
help: { category: 'system', summary: 'Show command help.', usage: [`${prefix} help [command|category]`] },
status: { category: 'system', summary: 'Show rover status; rover names can be fuzzy.', usage: [`${prefix} status [rover]`] },
replay: { category: 'system', summary: 'Create an instant replay from selected sources.', usage: [`${prefix} replay [sources]`] },
'time-status': { category: 'system', summary: 'Show the current time status.', usage: timeCommand ? [timeCommand] : [] },
lock: { category: 'admin', summary: 'Lock a rover.', usage: [`${prefix} lock <rover>`], access: 'Admin', permission: 'admin' },
unlock: { category: 'admin', summary: 'Unlock a rover.', usage: [`${prefix} unlock <rover>`], access: 'Admin', permission: 'admin' },
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
};
}
module.exports = { CATEGORIES, buildCommandRegistry };
@@ -0,0 +1,68 @@
// Web Chat Command Transport
// Purpose: Renders status-oriented operator commands as the same plain text web chat expects.
// Scope: Avoids importing Discord.js merely to flatten an embed back into text.
const { resolveRoverSelector } = require('./commands/resolvers');
function formatTimeInZone(date, timeZone) {
try {
return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date);
} catch (_err) {
return 'n/a';
}
}
function createWebTransportHandlers({ rovers, roverManager, config, siteUrl = '' }) {
return {
async status(message, roverId) {
const resolved = roverId ? resolveRoverSelector(roverId, rovers) : null;
if (roverId && resolved?.error) return message.reply(`Rover Status\n\n${resolved.error}`);
const records = roverId
? [resolved.record]
: Array.from(rovers.values()).filter((entry) => roverManager.canReplayRoverId(entry?.id));
if (!records.length) return message.reply('Rover Battery Status\n\nNo rovers online.');
// This mirrors the human-readable content of the established Discord
// battery embed while remaining a plain transport-neutral chat result.
const fields = records.map((record) => {
const sensors = record.lastSensor?.decoded || record.lastSensor?.sensors || {};
const battery = record.batteryState || {};
const name = record.meta?.name || record.id;
const docked = Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = String(sensors?.chargingState?.label || 'unknown');
const charging = ['waiting', 'full charging', 'trickle charging'].includes(chargingLabel.toLowerCase()) || [2, 3, 4].includes(sensors?.chargingState?.code);
const lockLabel = record.locked ? `locked${record.lockReason ? ` (${record.lockReason})` : ''}` : 'unlocked';
const charge = battery.charge != null && battery.capacity != null ? `${battery.charge}/${battery.capacity}mAh` : 'n/a';
const percent = battery.percentDisplay != null ? `${battery.percentDisplay}%` : 'n/a';
return [
name,
`Dock: ${docked ? 'docked' : 'undocked'}`,
`Charging: ${charging ? `charging (${chargingLabel})` : 'not charging'}`,
`Battery: ${charge} (${percent})`,
`Voltage: ${sensors?.voltageMv == null ? 'n/a' : `${(sensors.voltageMv / 1000).toFixed(2)}V`}`,
`Current: ${sensors?.currentMa == null ? 'n/a' : `${sensors.currentMa}mA`}`,
`OI: ${String(sensors?.oiMode?.label || 'unknown').toLowerCase()}`,
`Lock: ${lockLabel}`,
].join('\n');
});
return message.reply(['Rover Battery Status', ...fields].join('\n\n'));
},
async timeStatus(message) {
const serverTimezone = config.timezone || config.server?.timezone || process.env.TZ || 'America/New_York';
const zones = [
['UTC', 'UTC'], ['US Pacific', 'America/Los_Angeles'], ['US Mountain', 'America/Denver'],
['US Central', 'America/Chicago'], ['US Eastern', 'America/New_York'], ['Europe London', 'Europe/London'],
['Europe Berlin', 'Europe/Berlin'], ['Asia Kolkata', 'Asia/Kolkata'], ['Asia Shanghai', 'Asia/Shanghai'],
['Asia Tokyo', 'Asia/Tokyo'], ['Australia Sydney', 'Australia/Sydney'], ['New Zealand Auckland', 'Pacific/Auckland'],
];
const now = new Date();
const lines = zones.map(([label, zone]) => `${label}${formatTimeInZone(now, zone)}${zone.toLowerCase() === String(serverTimezone).toLowerCase() ? ' **(server local timezone)**' : ''}`);
if (!zones.some(([, zone]) => zone.toLowerCase() === String(serverTimezone).toLowerCase())) {
lines.push(`Server Local — ${formatTimeInZone(now, serverTimezone)} **(server local timezone)**`);
}
const siteLink = siteUrl ? `\n\n${siteUrl}` : '';
return message.reply(`Time Status\n${lines.join('\n')}${siteLink}\n\nServer local timezone: ${serverTimezone}`);
},
};
}
module.exports = { createWebTransportHandlers };
+61 -5
View File
@@ -11,6 +11,10 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('ptzCamera'); const logger = require('../../globals/logger').child('ptzCamera');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features'); const { isFeatureEnabled } = require('../../helpers/features');
const {
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService'); const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const { isVerified } = require('../verificationService'); const { isVerified } = require('../verificationService');
@@ -335,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) { function canSpeakThroughPtz(socket) {
/* /*
PTZ chat uses roverId for identity, but the camera has its own queue rather PTZ chat uses roverId for identity, but the camera has its own queue rather
@@ -1013,9 +1045,10 @@ function requirePtzUser(socket) {
function requirePresetAdmin(socket) { function requirePresetAdmin(socket) {
/* /*
Preset creation and deletion changes shared camera state for everyone. Keep Preset removal is intentionally narrower than normal PTZ operation because
that narrower than normal PTZ operation so regular camera users can only deleting a shared camera position is destructive for every future operator.
choose from positions an admin has intentionally published. Creation now uses requirePtzUser instead so any authorized PTZ user can save
a useful current position without also being allowed to remove presets.
*/ */
if (!enabled) throw new Error('PTZ camera disabled'); if (!enabled) throw new Error('PTZ camera disabled');
if (!passesMode(socket)) throw new Error('Not authorized for PTZ camera'); if (!passesMode(socket)) throw new Error('Not authorized for PTZ camera');
@@ -1117,7 +1150,7 @@ async function gotoPreset(socket, payload = {}) {
} }
async function createPreset(socket, payload = {}) { async function createPreset(socket, payload = {}) {
requirePresetAdmin(socket); requirePtzUser(socket);
await initialize(); await initialize();
const presetName = normalizePresetCreateName(payload.name || payload.presetName); const presetName = normalizePresetCreateName(payload.name || payload.presetName);
const options = { const options = {
@@ -1290,7 +1323,29 @@ function canRequestLiveVideo(socket) {
if (!enabled || !passesMode(socket)) return false; if (!enabled || !passesMode(socket)) return false;
if (state.operatorSocketId === socket?.id) return true; if (state.operatorSocketId === socket?.id) return true;
if (isAdmin(socket) || isLockdownAdmin(socket)) return true; if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
return isLocalNetwork(getSocketIp(socket)); const role = getRole(socket);
const local = isLocalNetwork(getSocketIp(socket));
if (role === 'spectator') {
/*
Spectator PTZ viewing follows the spectator bandwidth switch. LAN viewers
stay live because they do not consume server upload; non-local spectators
only get live PTZ when the external spectator video policy allows it.
*/
return local || !shouldUseSnapshotsForExternalSpectatorVideo();
}
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
to live, they may watch the live feed while waiting; camera movement still
remains limited to the active operator by the command handlers.
*/
return true;
}
return false;
} }
function getSnapshotPath() { function getSnapshotPath() {
@@ -1553,6 +1608,7 @@ module.exports = {
ptzCameraEvents: events, ptzCameraEvents: events,
getPublicState, getPublicState,
getChatTargetForSocket, getChatTargetForSocket,
getParticipantSocketIds,
canSpeakThroughPtz, canSpeakThroughPtz,
speakText, speakText,
canRequestLiveVideo, canRequestLiveVideo,
@@ -0,0 +1,109 @@
// Replay Delivery Service
// Purpose: Builds each web-requested replay once and chooses Discord or automatic local hosting.
// Scope: Keeps replay generation functional even when the optional Discord feature is disabled or unhealthy.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('replayDelivery');
const { subscribe } = require('../eventBus');
const { buildReplayVideo } = require('../replayEngineV2');
const { hostReplay } = require('../replayMediaService');
const {
createReplayJob,
createJobStatusEmitter,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('./workflow');
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions: (value) => String(value || '') });
let preferredDeliveryProvider = null;
function registerPreferredDeliveryProvider(provider) {
preferredDeliveryProvider = provider && typeof provider.deliver === 'function' ? provider : null;
return () => {
if (preferredDeliveryProvider === provider) preferredDeliveryProvider = null;
};
}
async function deliverReplay(payload = {}) {
const job = createReplayJob({
id: payload.jobId,
requester: payload.requester,
source: 'web',
title: payload.title,
sources: payload.sources,
includeSidebar: payload.includeSidebar,
requestedBy: payload.requestedBy,
});
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerContext = null;
try {
let providerError = null;
if (preferredDeliveryProvider?.begin) {
try {
providerContext = await preferredDeliveryProvider.begin(job);
} catch (err) {
providerError = err;
logger.warn('Preferred replay delivery could not start; using hosted media', { jobId: job.id, error: err.message });
}
}
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (providerContext?.progressMessage?.edit) {
await providerContext.progressMessage.edit({ content: buildStatusMessage(job, 'building'), allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
}
const built = await buildReplayVideo({
sources: job.sources,
title: job.title,
requester: job.requester,
includeSidebar: job.includeSidebar,
});
let media = null;
if (preferredDeliveryProvider && !providerError) {
try {
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
media = await preferredDeliveryProvider.deliver({ job, context: providerContext, ...built });
} catch (err) {
providerError = err;
if (!providerError.progressMessage && providerContext?.progressMessage) providerError.progressMessage = providerContext.progressMessage;
logger.warn('Preferred replay delivery failed; using hosted media', { jobId: job.id, error: err.message });
}
}
if (!media) media = await hostReplay({ buffer: built.buffer, job });
jobStatus.emit(job, 'ready', { message: buildStatusMessage(job, 'ready'), media });
if (providerError && preferredDeliveryProvider?.completeFallback) {
await preferredDeliveryProvider.completeFallback({ job, context: providerContext, media }).catch((err) => {
logger.warn('Unable to announce hosted replay fallback', { jobId: job.id, error: err.message });
});
}
if (providerContext?.stopTyping) providerContext.stopTyping();
// A Discord progress message may already exist when upload fails. Let the
// provider attach it to the error so fallback can finish that outward UI
// instead of leaving a permanent "uploading" message in the channel.
if (providerError?.progressMessage?.edit) {
await providerError.progressMessage.edit({ content: buildStatusMessage(job, 'ready'), allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
}
return media;
} catch (err) {
if (providerContext?.stopTyping) providerContext.stopTyping();
const message = normalizeUserError(err);
jobStatus.emit(job, 'failed', { message });
if (providerContext?.progressMessage?.edit) {
await providerContext.progressMessage.edit({ content: message, allowedMentions: { parse: [], repliedUser: false } }).catch(() => {});
}
throw err;
}
}
subscribe('replay.requested', (event) => {
deliverReplay(event?.payload || {}).catch((err) => {
logger.warn('Replay delivery failed', { error: err.message });
});
});
module.exports = {
deliverReplay,
registerPreferredDeliveryProvider,
};
@@ -1,6 +1,6 @@
// Discord Replay Workflow // Replay Delivery Workflow
// Purpose: Provides the shared replay job, Discord upload, fuzzy source lookup, and user-facing status helpers. // Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
// Scope: Keeps Discord-command and web-triggered replay delivery on the same status pipeline. // Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false }; const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
@@ -7,11 +7,7 @@ const { getMode, MODES } = require('../modeManager');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
const { getNickname } = require('../nicknameService'); const { getNickname } = require('../nicknameService');
const { loadConfig } = require('../../helpers/configLoader'); const { buildReplayJobId, buildReplayTitle } = require('../replayDeliveryService/workflow');
const { buildReplayJobId, buildReplayTitle } = require('../discordBotService/replayWorkflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function buildRequesterLabel(socket) { function buildRequesterLabel(socket) {
return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown'; return getNickname(socket) || socket?.data?.user?.username || socket?.id || 'unknown';
@@ -34,11 +30,6 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
cb({ error: 'Replay disabled in lockdown', state: null }); cb({ error: 'Replay disabled in lockdown', state: null });
return; return;
} }
const channelId = discordConfig?.channels?.replay || null;
if (!channelId) {
cb({ error: 'Replay channel not configured', state: null });
return;
}
const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null; const requestedSources = Array.isArray(payload?.sources) ? payload.sources : null;
let sources = requestedSources ? validateSources(requestedSources, socket) : []; let sources = requestedSources ? validateSources(requestedSources, socket) : [];
if (!sources.length) { if (!sources.length) {
@@ -65,7 +56,6 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
type: 'replay.requested', type: 'replay.requested',
payload: { payload: {
jobId, jobId,
channelId,
requester, requester,
title, title,
includeSidebar, includeSidebar,
@@ -0,0 +1,123 @@
// Replay Media Service
// Purpose: Stores and serves completed replay videos when Discord delivery is unavailable.
// Scope: Owns only final hosted MP4 files; replay frame caches and active video builds remain outside this service.
const crypto = require('crypto');
const fsp = require('fs/promises');
const path = require('path');
const logger = require('../../globals/logger').child('replayMedia');
const { app } = require('../../globals/http');
const { resolveDataDir } = require('../../helpers/dataPaths');
const REPLAY_DIR = path.join(resolveDataDir(), 'replays');
const MAX_AGE_MS = 6 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
const MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
const PUBLIC_FILE_PATTERN = /^[a-f0-9]{32}\.mp4$/;
async function listCompletedFiles() {
await fsp.mkdir(REPLAY_DIR, { recursive: true });
const entries = await fsp.readdir(REPLAY_DIR, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(REPLAY_DIR, entry.name);
try {
const stat = await fsp.stat(filePath);
files.push({ name: entry.name, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs });
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Unable to inspect hosted replay', { file: entry.name, error: err.message });
}
}
return files;
}
async function cleanup() {
const now = Date.now();
const files = await listCompletedFiles();
const completed = files.filter((file) => PUBLIC_FILE_PATTERN.test(file.name)).sort((a, b) => a.mtimeMs - b.mtimeMs);
const temporary = files.filter((file) => file.name.endsWith('.tmp'));
// Temporary files are never served. An old one means a write was interrupted,
// so it is safe to remove after the same conservative expiry used for media.
const expiredTemporary = temporary.filter((file) => now - file.mtimeMs > MAX_AGE_MS);
const expiredCompleted = completed.filter((file) => now - file.mtimeMs > MAX_AGE_MS);
const toDelete = new Set([...expiredTemporary, ...expiredCompleted].map((file) => file.path));
let retainedBytes = completed.reduce((total, file) => total + file.size, 0)
- expiredCompleted.reduce((total, file) => total + file.size, 0);
for (const file of completed) {
if (retainedBytes <= MAX_TOTAL_BYTES) break;
if (toDelete.has(file.path)) continue;
toDelete.add(file.path);
retainedBytes -= file.size;
}
await Promise.all(Array.from(toDelete).map(async (filePath) => {
try {
await fsp.unlink(filePath);
} catch (err) {
if (err.code !== 'ENOENT') logger.warn('Unable to remove hosted replay', { file: path.basename(filePath), error: err.message });
}
}));
}
async function hostReplay({ buffer, job }) {
if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Replay output was empty');
await fsp.mkdir(REPLAY_DIR, { recursive: true });
const filename = `${crypto.randomBytes(16).toString('hex')}.mp4`;
const finalPath = path.join(REPLAY_DIR, filename);
const temporaryPath = `${finalPath}.${process.pid}.tmp`;
// Atomic rename ensures cleanup and HTTP requests can only observe a fully
// written MP4, never a partially flushed replay.
try {
await fsp.writeFile(temporaryPath, buffer, { flag: 'wx' });
await fsp.rename(temporaryPath, finalPath);
} catch (err) {
await fsp.unlink(temporaryPath).catch(() => {});
throw err;
}
return {
jobId: job.id,
status: 'ready',
title: job.title,
requester: job.requester,
requestedBy: job.requestedBy || null,
url: `/media/replays/${filename}`,
proxyUrl: null,
messageUrl: null,
filename,
size: buffer.length,
contentType: 'video/mp4',
sources: Array.isArray(job.sources) ? job.sources : [],
ts: Date.now(),
};
}
app.get('/media/replays/:filename', (req, res, next) => {
const filename = String(req.params.filename || '');
if (!PUBLIC_FILE_PATTERN.test(filename)) return res.status(404).end();
const filePath = path.join(REPLAY_DIR, filename);
// Express sendFile supports byte-range requests, which preserves seeking in
// the existing browser video players without implementing a second streamer.
res.setHeader('Cache-Control', 'private, max-age=3600');
return res.sendFile(filePath, { headers: { 'Content-Type': 'video/mp4' } }, (err) => {
if (!err || res.headersSent) return;
if (err.code === 'ENOENT') return res.status(404).end();
return next(err);
});
});
cleanup().catch((err) => logger.warn('Initial hosted replay cleanup failed', err.message));
const cleanupTimer = setInterval(() => {
cleanup().catch((err) => logger.warn('Hosted replay cleanup failed', err.message));
}, CLEANUP_INTERVAL_MS);
// Maintenance must never keep a process alive during normal shutdown.
if (typeof cleanupTimer.unref === 'function') cleanupTimer.unref();
module.exports = {
hostReplay,
cleanup,
replayDirectory: REPLAY_DIR,
};
+92 -13
View File
@@ -3,7 +3,7 @@
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const io = require('../../globals/io'); const io = require('../../globals/io');
const logger = require('../../globals/logger').child('sessionService'); const logger = require('../../globals/logger').child('sessionService');
const { getRole, roleEvents } = require('../roleService'); const { getRole, isAdmin, roleEvents } = require('../roleService');
const { getMode, modeEvents } = require('../modeManager'); const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const { managerEvents } = roverManager; const { managerEvents } = roverManager;
@@ -20,6 +20,10 @@ const { getState: getHomeAssistantState, homeAssistantEvents } = require('../hom
const { getState: getNeatoState, neatoEvents } = require('../neatoService'); const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService'); const { getState: getLiftState, liftEvents } = require('../liftService');
const { getState: getKinectState, kinectEvents } = require('../kinectService'); const { getState: getKinectState, kinectEvents } = require('../kinectService');
const {
getState: getBalanceBoardState,
balanceBoardEvents,
} = require('../balanceBoardService');
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService'); const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
const { getNickname, nicknameEvents } = require('../nicknameService'); const { getNickname, nicknameEvents } = require('../nicknameService');
const { const {
@@ -39,6 +43,15 @@ const { getAdminReason } = require('../adminReasonService');
const { subscribe } = require('../eventBus'); const { subscribe } = require('../eventBus');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver'); const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const { getFeatureFlags } = require('../../helpers/features'); const { getFeatureFlags } = require('../../helpers/features');
const {
canUseExternalSpectatorAccess,
getBandwidthSavingsPolicy,
shouldUseSnapshotsForNonTurnVideo,
} = require('../../helpers/bandwidthSavings');
const {
getFeatureState,
getUserIdForSocket,
} = require('../identityService');
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService'); const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService'); const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
const { getButtonBoxState } = require('../buttonBoxService'); const { getButtonBoxState } = require('../buttonBoxService');
@@ -62,6 +75,59 @@ logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configure
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured'); logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured'); logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
function hasExternalSpectatorGrant(socket) {
const userId = getUserIdForSocket(socket);
if (!userId) return false;
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
return Boolean(state?.external);
}
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
decisions in auth and video services, so the UI remains advisory only.
*/
externalSpectatorGranted: granted,
canUseExternalSpectatorAccess: canUseExternalSpectatorAccess({
isLocal: local,
isAdmin: isAdmin(socket),
isVerified: Boolean(socket?.data?.isVerified),
hasGrant: granted,
}),
};
}
function countControllableUsers(userEntries = []) {
const ids = new Set();
userEntries.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const socketId = String(entry?.socketId || '').trim();
const roverId = String(entry?.roverId || '').trim();
/*
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
id and normal drivers to their physical rover. Counting entries after that
normalization gives the browser the same conceptual "controllable users"
count it shows in the user/queue panels without duplicating PTZ UI logic.
*/
if (socketId && roverId) ids.add(socketId);
});
return ids.size;
}
function buildUserEntry(socket) { function buildUserEntry(socket) {
if (!socket) return null; if (!socket) return null;
const role = getRole(socket); const role = getRole(socket);
@@ -86,19 +152,22 @@ function buildUserEntry(socket) {
function buildSession(socket) { function buildSession(socket) {
const overseerVote = getOverseerVoteStatus(); const overseerVote = getOverseerVoteStatus();
const features = getFeatureFlags(); const features = getFeatureFlags();
const users = Array.from(io.sockets.sockets.values()) const userEntries = Array.from(io.sockets.sockets.values())
.map((sock) => buildUserEntry(sock)) .map((sock) => buildUserEntry(sock))
.filter(Boolean) .filter(Boolean);
.map((entry) => ({ const controllableUserCount = countControllableUsers(userEntries);
...entry, 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 PTZ is intentionally not a roverManager record, so the normal physical
it here because getPtzChatTargetForSocket already applied the PTZ access rover visibility filter would erase the user's PTZ chat target. Preserve
and queue/operator rules before buildUserEntry returned it. 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), */
})); roverId: entry.roverId === PTZ_CAMERA_ID
? entry.roverId
: filterVisibleRoverId(socket, entry.roverId),
}));
const roster = roverManager.getRosterForSocket(socket); const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || ''); const assignment = assignmentService.describeAssignment(socket?.id || '');
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId); const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId);
@@ -110,6 +179,7 @@ function buildSession(socket) {
role: getRole(socket), role: getRole(socket),
mode: getMode(), mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)), isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
/* /*
Features is the single UI contract for optional server capabilities. A Features is the single UI contract for optional server capabilities. A
disabled feature should be absent from navigation/layout decisions even disabled feature should be absent from navigation/layout decisions even
@@ -131,6 +201,7 @@ function buildSession(socket) {
neato: getNeatoState(), neato: getNeatoState(),
lift: getLiftState(), lift: getLiftState(),
kinect: getKinectState(), kinect: getKinectState(),
balanceBoard: getBalanceBoardState(),
replay: getReplayState(), replay: getReplayState(),
replaySources: getReplaySources(socket), replaySources: getReplaySources(socket),
health: getHealthSnapshot(), health: getHealthSnapshot(),
@@ -331,6 +402,14 @@ kinectEvents.on('change', () => {
syncAll(); syncAll();
}); });
balanceBoardEvents.on('change', () => {
// Live weight frames use their own Socket.IO room because they change much
// faster than the full session. Only connection/status changes reach this
// listener, keeping session sync inexpensive while the panel stays current.
logger.info('Balance Board state change; syncing all clients');
syncAll();
});
replayEvents.on('update', () => { replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients'); logger.info('Replay cooldown updated; syncing all clients');
syncAll(); syncAll();
@@ -31,6 +31,7 @@ const {
resolveUserBySelector, resolveUserBySelector,
userToLegacyIdentityEntry, userToLegacyIdentityEntry,
} = require('../identityService'); } = require('../identityService');
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
const verificationEvents = new EventEmitter(); const verificationEvents = new EventEmitter();
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000; const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
@@ -103,7 +104,7 @@ function identifySocket(socket, payload = {}) {
nickname: incomingNickname || getNickname(socket) || '', nickname: incomingNickname || getNickname(socket) || '',
}); });
refreshSocketIdentityFlags(socket); refreshSocketIdentityFlags(socket);
enforceSingleUnverifiedSocketPerIdentity(socket); enforceSingleDriverSocketPerIdentity(socket);
emitChange('identify', { socketId: socket.id, userId: result.userId }); emitChange('identify', { socketId: socket.id, userId: result.userId });
return { return {
@@ -132,13 +133,18 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS); }, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
} }
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) { function enforceSingleDriverSocketPerIdentity(currentSocket) {
const currentUserId = getUserIdForSocket(currentSocket); const currentUserId = getUserIdForSocket(currentSocket);
const currentRole = getRole(currentSocket);
const enforceForCurrentSocket = shouldEnforceSingleDriverTab({
isVerified: Boolean(currentSocket?.data?.isVerified),
isAdmin: isAdminRole(currentRole),
});
if ( if (
!currentSocket?.id || !currentSocket?.id ||
!currentUserId || !currentUserId ||
currentSocket.data?.isVerified || currentSocket.data?.identitySurface !== 'driver' ||
currentSocket.data?.identitySurface !== 'driver' !enforceForCurrentSocket
) { ) {
return; return;
} }
@@ -150,9 +156,21 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
}); });
if (!duplicates.length) return; if (!duplicates.length) return;
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified); const verifiedDuplicate = duplicates.find((candidate) => {
/*
verifiedOnly keeps the previous "verified tab wins" rule. In notAllowed
mode, verified users are subject to the same single-driver-tab rule, so a
verified duplicate should not protect the newer socket from enforcement.
*/
const candidateRole = getRole(candidate);
const enforceForCandidate = shouldEnforceSingleDriverTab({
isVerified: Boolean(candidate?.data?.isVerified),
isAdmin: isAdminRole(candidateRole),
});
return candidate?.data?.isVerified && !enforceForCandidate;
});
if (verifiedDuplicate) { if (verifiedDuplicate) {
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', { logger.info('Disconnecting driver socket because its user is already active on an exempt verified socket', {
socketId: currentSocket.id, socketId: currentSocket.id,
retainedSocketId: verifiedDuplicate.id, retainedSocketId: verifiedDuplicate.id,
userId: currentUserId, userId: currentUserId,
@@ -162,7 +180,7 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
} }
duplicates.forEach((duplicate) => { duplicates.forEach((duplicate) => {
logger.info('Disconnecting older non-verified duplicate user socket', { logger.info('Disconnecting older duplicate driver socket', {
socketId: duplicate.id, socketId: duplicate.id,
retainedSocketId: currentSocket.id, retainedSocketId: currentSocket.id,
userId: currentUserId, userId: currentUserId,
@@ -30,6 +30,7 @@ const { canAccessStream } = createVideoAuthPolicy({
ptzCameraService, ptzCameraService,
getSocketIp, getSocketIp,
isLocalNetwork, isLocalNetwork,
io,
}); });
registerVideoAuthRoute({ registerVideoAuthRoute({
+41 -1
View File
@@ -1,6 +1,11 @@
// Video Auth Policy // Video Auth Policy
// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks. // Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks.
// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state. // Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state.
const {
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
function createVideoAuthPolicy(deps) { function createVideoAuthPolicy(deps) {
const { const {
getMode, getMode,
@@ -14,8 +19,31 @@ function createVideoAuthPolicy(deps) {
ptzCameraService, ptzCameraService,
getSocketIp, getSocketIp,
isLocalNetwork, isLocalNetwork,
io,
} = deps; } = deps;
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
/*
MediaMTX can ask for authorization after a browser has already received
a token, so this count intentionally mirrors videoSocketService instead
of trusting the client-visible session policy snapshot.
*/
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
if (typeof ptzCameraService.getParticipantSocketIds === 'function') {
ptzCameraService.getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
}
return ids.size;
}
function canView(socket) { function canView(socket) {
const mode = getMode(); const mode = getMode();
if (!socket) return false; if (!socket) return false;
@@ -63,7 +91,7 @@ function createVideoAuthPolicy(deps) {
const isAudio = streamInfo.id?.endsWith('-audio'); const isAudio = streamInfo.id?.endsWith('-audio');
if (role === 'spectator' && !isAdmin(socket) && !isAudio) { if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
const socketIp = getSocketIp(socket); const socketIp = getSocketIp(socket);
if (!isLocalNetwork(socketIp)) { if (!isLocalNetwork(socketIp) && shouldUseSnapshotsForExternalSpectatorVideo()) {
return false; return false;
} }
} }
@@ -73,6 +101,18 @@ function createVideoAuthPolicy(deps) {
if (!roverManager.isDriver(roverId, socket)) { if (!roverManager.isDriver(roverId, socket)) {
return false; return false;
} }
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
be evaluated here too instead of trusting an older browser decision.
*/
return false;
}
} }
return true; return true;
@@ -8,8 +8,13 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
const videoSessions = require('../videoSessions'); const videoSessions = require('../videoSessions');
const roverManager = require('../roverManager'); const roverManager = require('../roverManager');
const ptzCameraService = require('../ptzCameraService'); const ptzCameraService = require('../ptzCameraService');
const turnService = require('../turnService');
const { loadConfig } = require('../../helpers/configLoader'); const { loadConfig } = require('../../helpers/configLoader');
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver'); const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
const {
shouldUseSnapshotsForNonTurnVideo,
shouldUseSnapshotsForExternalSpectatorVideo,
} = require('../../helpers/bandwidthSavings');
const config = loadConfig(); const config = loadConfig();
const mediaConfig = config.media || {}; const mediaConfig = config.media || {};
@@ -80,6 +85,29 @@ function canViewRoomCamera(socket) {
return passesMode(socket); return passesMode(socket);
} }
function countControllableUsers() {
const ids = new Set();
io.sockets.sockets.forEach((candidate) => {
if (!candidate?.id || getRole(candidate) === 'spectator') return;
/*
Rover drivers and PTZ participants are both "controllable" users for this
bandwidth decision because either group can create a non-turn video view.
Counting unique socket ids prevents someone who is transitioning between
rover and PTZ from being counted twice.
*/
if (roverManager.getRoversForSocket(candidate.id).length > 0) {
ids.add(candidate.id);
}
});
if (typeof ptzCameraService.getParticipantSocketIds === 'function') {
ptzCameraService.getParticipantSocketIds().forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket && getRole(socket) !== 'spectator') ids.add(socketId);
});
}
return ids.size;
}
function normalizeRequest(payload = {}) { function normalizeRequest(payload = {}) {
if (!payload) return null; if (!payload) return null;
if (payload.type && payload.id) { if (payload.type && payload.id) {
@@ -116,10 +144,25 @@ io.on('connection', (socket) => {
const role = getRole(socket); const role = getRole(socket);
if (role === 'spectator' && !isAdmin(socket) && !isAudio) { if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
const ip = getSocketIp(socket); const ip = getSocketIp(socket);
if (!isLocalNetwork(ip)) { if (!isLocalNetwork(ip) && shouldUseSnapshotsForExternalSpectatorVideo()) {
throw new Error('Not authorized for video'); throw new Error('Not authorized for video');
} }
} }
if (
!isAudio &&
role !== 'spectator' &&
!isAdmin(socket) &&
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
!turnService.canDrive(baseId, socket)
) {
/*
The browser also forces snapshots for non-active turn holders, but
the socket token path must enforce the same rule. Otherwise a stale
component or direct socket caller could still mint a MediaMTX token
while the UI is showing snapshots.
*/
throw new Error('Live video is limited to the active turn');
}
} else if (target.type === 'room') { } else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed'); throw new Error('Room cameras now use the snapshot feed');
} else if (target.type === 'ptz') { } else if (target.type === 'ptz') {
+5 -9
View File
@@ -1,12 +1,8 @@
1. improve spectator page, options on what to see and what not to see 1. assign rovers based on battery percentage, give people highest one
2. assign rovers based on battery percentage, give people highest one 2. add admin ui for VIP and private requests instead of only through discord
3. add config to disable client snapshot forcing, disable bandwidth saving 3. make overcurrent limiter speed sensitive, slower fill at lower speeds
4. add admin ui for VIP and private requests instead of only through discord 4. add more background gap themes
5. pull page / tab title from session, use the name from profile of interinstance if enabled, if not just default to old one 5. fix this:
6. make overcurrent limiter speed sensitive, slower fill at lower speeds
7. add feature chat commands, like /neato start, /lift up, etc
8. add more background gap themes
9. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92 `Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message }); Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^ Jun 18 15:14:18 roombaserver.local node[216731]: ^
+3 -30
View File
@@ -15,6 +15,7 @@ import {
} from './controls/index.js'; } from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx'; import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
import KinectPanel from './components/KinectPanel/index.jsx'; import KinectPanel from './components/KinectPanel/index.jsx';
import BalanceBoardPanel from './components/BalanceBoardPanel/index.jsx';
import DriverVideo from './components/DriverVideo/index.jsx'; import DriverVideo from './components/DriverVideo/index.jsx';
import RightPaneTabs from './components/RightPaneTabs/index.jsx'; import RightPaneTabs from './components/RightPaneTabs/index.jsx';
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx'; import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
@@ -52,36 +53,7 @@ import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx'; import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js'; import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
import { trackAnalyticsEvent } from './analytics/index.js'; import { trackAnalyticsEvent } from './analytics/index.js';
import useLayoutMode from './hooks/useLayoutMode.js';
function useLayoutMode() {
const [mode, setMode] = useState(() => {
if (typeof window === 'undefined') return 'desktop';
return window.innerWidth >= 1024
? 'desktop'
: window.innerWidth > window.innerHeight
? 'mobile-landscape'
: 'mobile-portrait';
});
useEffect(() => {
function updateMode() {
if (typeof window === 'undefined') return;
const { innerWidth, innerHeight } = window;
if (innerWidth >= 1024) {
setMode('desktop');
} else if (innerWidth > innerHeight) {
setMode('mobile-landscape');
} else {
setMode('mobile-portrait');
}
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
function DesktopLayout({ layout, onOpenHelpOverlay }) { function DesktopLayout({ layout, onOpenHelpOverlay }) {
return ( return (
@@ -200,6 +172,7 @@ function MobileFeatureTabs({
<div className={`flex flex-col ${themeGapClass}`}> <div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard /> <NeatoCard />
<LiftCard /> <LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel /> <BarcodeGamesPanel />
<OdometerPanel /> <OdometerPanel />
<ButtonBoxPanel /> <ButtonBoxPanel />
@@ -69,6 +69,7 @@ export default function AdminPanelContent() {
setAdminReason, setAdminReason,
rebootRover, rebootRover,
updateRover, updateRover,
updateAllRovers,
rebootServer, rebootServer,
setAudioLevels, setAudioLevels,
setPrivateSafety, setPrivateSafety,
@@ -181,6 +182,35 @@ export default function AdminPanelContent() {
} }
}; };
const handleUpdateAll = async () => {
const roverCount = roster.filter((rover) => rover?.id).length;
if (roverCount === 0) return;
const ok = window.confirm(
`Update all ${roverCount} rover${roverCount === 1 ? '' : 's'} now? Each rover will run its self-update and reboot if the update starts successfully.`,
);
if (!ok) return;
trackAnalyticsEvent('rover_update_all_click', { roverCount });
try {
// The server owns the fan-out because it has the authoritative online
// rover map and can enforce admin privileges once before issuing the
// existing per-rover update command to every connected rover.
const result = await updateAllRovers();
trackAnalyticsEvent('rover_update_all_result', {
status: 'accepted',
updated: result?.updated?.length || 0,
failed: result?.failed?.length || 0,
});
if (result?.failed?.length) {
alert(`Update requested for ${result.updated?.length || 0} rover(s). ${result.failed.length} rover(s) failed to queue.`);
}
} catch (err) {
trackAnalyticsEvent('rover_update_all_result', { status: 'failed', reason: err?.message || 'unknown' });
alert(err.message);
}
};
const handleServerReboot = async () => { const handleServerReboot = async () => {
const ok = window.confirm('Reboot the server host now? This will disconnect all users.'); const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
if (!ok) return; if (!ok) return;
@@ -487,6 +517,17 @@ export default function AdminPanelContent() {
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-0.5 text-xs">
<span className="text-slate-400">Rovers</span>
<button
type="button"
onClick={handleUpdateAll}
disabled={roster.length === 0}
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
>
Update All
</button>
</div>
<RoverRoster <RoverRoster
roster={roster} roster={roster}
renderActions={(rover) => ( renderActions={(rover) => (
@@ -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>
);
}
@@ -187,9 +187,13 @@ function HomeAssistantControlsContent() {
const ha = useSessionSelector((state) => state.session?.homeAssistant || null); const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } = const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions(); useSessionActions();
const role = useSessionSelector((state) => state.session?.role || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const entities = useMemo(() => ha?.entities || [], [ha?.entities]); const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const lightPolicy = ha?.lightPolicy || null; const lightPolicy = ha?.lightPolicy || null;
const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn); const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
const lightPolicyLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
const controlsLocked = lightPolicyLocked && !adminCanControlLockedLights;
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null); const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]); const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]); const offKeyLabel = formatKeyLabel(keymap?.homeAssistantOff?.[0]);
@@ -224,18 +228,22 @@ function HomeAssistantControlsContent() {
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null} {offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
</span> </span>
</div> </div>
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null} {lightPolicyLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} /> <StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
</> </>
); );
return ( return (
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base"> <CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
{controlsLocked ? ( {lightPolicyLocked ? (
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100"> <p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
{lockState === 'off' {adminCanControlLockedLights
? 'Lights are locked off. Room controls are disabled.' ? lockState === 'off'
: 'Lights are locked on. Room controls are disabled.'} ? 'Lights are locked off. Admin room controls remain available.'
: 'Lights are locked on. Admin room controls remain available.'
: lockState === 'off'
? 'Lights are locked off. Room controls are disabled.'
: 'Lights are locked on. Room controls are disabled.'}
</p> </p>
) : null} ) : null}
<div className="grid grid-cols-1 gap-0.5 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-0.5 sm:grid-cols-2 lg:grid-cols-3">
+279 -116
View File
@@ -1,14 +1,16 @@
// PTZ Camera UI // PTZ Camera UI
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a // Purpose: Integrates the single PTZ camera into the main rover UI flow as a
// queueable controllable target instead of a VIP-panel card. // queueable controllable target instead of a VIP-panel card.
// Scope: Owns PTZ entry card and fullscreen composition; PTZ command authority, // Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
// queue ownership, and stream authorization remain server-owned. // composition; PTZ command authority, queue ownership, and stream authorization
// remain server-owned.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx'; import ChatPanel from '../ChatPanel/index.jsx';
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx'; import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx'; import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx'; import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx'; import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx'; import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
@@ -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' }) { function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz); const lookupUser = usePtzQueueLookup(ptz);
@@ -223,7 +186,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
} }
function PtzMobileZoomButtons({ disabled = false }) { function PtzMobileZoomButtons({ disabled = false }) {
const { nudgeServo, stopAllMotion } = useControlActions(); const { nudgeServo } = useControlActions();
const repeatTimerRef = useRef(null); const repeatTimerRef = useRef(null);
const stopZoom = useCallback(() => { const stopZoom = useCallback(() => {
@@ -238,8 +201,13 @@ function PtzMobileZoomButtons({ disabled = false }) {
clearInterval(repeatTimerRef.current); clearInterval(repeatTimerRef.current);
repeatTimerRef.current = null; 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( const startZoom = useCallback(
(direction) => (event) => { (direction) => (event) => {
@@ -372,6 +340,7 @@ function PtzPresetPanel({ ptz }) {
const [busy, setBusy] = useState(''); const [busy, setBusy] = useState('');
const presets = Array.isArray(ptz?.presets) ? ptz.presets : []; const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
const isPresetAdmin = role === 'admin' || role === 'lockdown'; const isPresetAdmin = role === 'admin' || role === 'lockdown';
const canCreatePreset = Boolean(ptz?.canUse);
const canMoveToPreset = Boolean(ptz?.isOperator); const canMoveToPreset = Boolean(ptz?.isOperator);
const refreshPresets = async () => { const refreshPresets = async () => {
@@ -410,13 +379,14 @@ function PtzPresetPanel({ ptz }) {
const createPreset = async (event) => { const createPreset = async (event) => {
event.preventDefault(); event.preventDefault();
const trimmed = name.trim(); const trimmed = name.trim();
if (!isPresetAdmin || busy || !trimmed) return; if (!canCreatePreset || busy || !trimmed) return;
setBusy('create'); setBusy('create');
try { try {
/* /*
ONVIF setPreset stores the camera's current physical position. The UI ONVIF setPreset stores the camera's current physical position. The UI
only sends the admin's label; the server supplies the active profile only sends the user's label; the server supplies the active profile
token so browser code does not need to know camera profile internals. token so browser code does not need camera profile internals, and the
server still enforces the PTZ feature gate for raw socket callers.
*/ */
await ptzCreatePreset({ name: trimmed }); await ptzCreatePreset({ name: trimmed });
setName(''); setName('');
@@ -461,15 +431,20 @@ function PtzPresetPanel({ ptz }) {
{ptz.presetsError} {ptz.presetsError}
</div> </div>
) : null} ) : null}
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
{/*
Presets should behave like a compact pile of actions, not a table.
flex-wrap lets each preset keep its natural button width and only
starts a new visual line when the current line runs out of room.
*/}
{presets.length ? presets.map((preset) => { {presets.length ? presets.map((preset) => {
const gotoBusy = busy === `goto:${preset.token}`; const gotoBusy = busy === `goto:${preset.token}`;
const removeBusy = busy === `remove:${preset.token}`; const removeBusy = busy === `remove:${preset.token}`;
return ( return (
<div key={preset.token} className="surface grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1"> <div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
<button <button
type="button" type="button"
className="button-dark min-w-0 truncate text-left text-xs disabled:opacity-50" className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
disabled={!canMoveToPreset || Boolean(busy)} disabled={!canMoveToPreset || Boolean(busy)}
onClick={() => goToPreset(preset)} onClick={() => goToPreset(preset)}
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'} title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
@@ -494,10 +469,10 @@ function PtzPresetPanel({ ptz }) {
</div> </div>
)} )}
</div> </div>
{isPresetAdmin ? ( {canCreatePreset ? (
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}> <form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
<input <input
className="min-w-0 rounded border border-slate-700 bg-black px-2 py-1 text-xs text-slate-100 outline-none focus:border-cyan-300" className="field-input min-w-0 text-xs"
value={name} value={name}
maxLength={60} maxLength={60}
disabled={Boolean(busy)} disabled={Boolean(busy)}
@@ -533,13 +508,32 @@ function buildPtzTurnModel(ptz, selfId) {
function PtzMediaPane({ ptz, open, framed = true }) { function PtzMediaPane({ ptz, open, framed = true }) {
const isOperator = Boolean(ptz?.isOperator); const isOperator = Boolean(ptz?.isOperator);
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator }); /*
PTZ has its own turn queue, so non-operators are the camera equivalent of a
non-active rover driver. The server enforces the same policy in
canRequestLiveVideo(); this branch only chooses the expected browser render
path and never unlocks movement controls.
*/
/*
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 snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]); const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
const media = ( const media = (
<> <>
{isOperator ? ( {shouldUseLiveVideo ? (
<PtzLiveVideo enabled={open} startMuted={false} /> <PtzLiveVideo enabled={open} startMuted={false} />
) : ( ) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} /> <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
@@ -578,8 +572,13 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
</CardFrame> </CardFrame>
)} )}
<PtzControlReference /> <PtzControlReference />
<PtzStatePanel ptz={ptz} /> <ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
<ReplaySourcesPanel panelId="ptz-controller-replay" /> {/*
Desktop keeps room controls as the final sidebar tool so camera
turn controls and replay remain above the less-frequent room-wide
actions. HomeAssistantControls owns its own feature and policy gate.
*/}
<HomeAssistantControls />
</aside> </aside>
</div> </div>
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden"> <div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
@@ -595,78 +594,248 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
); );
} }
function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) { function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
const landscape = layout === 'mobile-landscape';
const topHeightClass = landscape ? 'h-full min-h-[calc(100dvh-0.25rem)]' : 'h-[48dvh]';
const topGridClass = landscape
? 'grid-cols-[minmax(0,1fr)_13rem]'
: 'grid-cols-[minmax(0,1fr)_11rem]';
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5"> <div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
<section className={`mobile-touch-control grid ${topHeightClass} min-h-48 shrink-0 ${topGridClass} gap-0.5`}> {/*
<main className="relative min-h-0 overflow-hidden bg-black"> Landscape intentionally retains one control column beside the video.
<button This is the established PTZ interaction and avoids forcing rover-style
type="button" left/right columns onto a camera that has a smaller control inventory.
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} <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">
onClick={onClose} {/*
> The video keeps one viewport of height, but the grid row is allowed to
Close grow when the control column is taller. That makes the sidebar's tail
</button> extend below the video instead of forcing it into a nested scroller.
<PtzMediaPane ptz={ptz} open framed={false} /> */}
</main> <div className="min-w-0 space-y-0.5">
<aside className="min-h-0 overflow-y-auto"> <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} /> <PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-landscape"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</aside> </aside>
</section> </section>
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]"> <section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" /> <ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
<div className="space-y-0.5"> <PtzPresetPanel ptz={ptz} />
<PtzQueueSummary ptz={ptz} />
<PtzPresetPanel ptz={ptz} />
<PtzStatePanel ptz={ptz} compact />
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
</div>
</section> </section>
</div> </div>
); );
} }
export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) { function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
<button
type="button"
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
disabled={releasePending}
onClick={onClose}
>
Close
</button>
<PtzMediaPane ptz={ptz} open framed={false} />
</main>
{/*
Portrait gives the video its full available width and places controls
below it. Reusing the landscape sidebar width here was the source of the
cramped portrait presentation, while the controls themselves remain the
same shared PTZ controls used in landscape.
*/}
<section className="mobile-touch-control">
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
</section>
<section className="space-y-0.5">
{/*
Replay and presets are compact secondary actions, so portrait places
them in one equal-width row before the full-width queue and chat. The
explicit two-column grid keeps this arrangement local to portrait and
leaves the desktop and one-column landscape compositions unchanged.
*/}
<div className="grid grid-cols-2 items-start gap-0.5">
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-portrait"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
<PtzPresetPanel ptz={ptz} />
</div>
<PtzQueueSummary ptz={ptz} />
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
{/* Portrait keeps room controls immediately after chat as requested. */}
<HomeAssistantControls />
</section>
</div>
);
}
export function PtzControllerPage({ layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const { ptzRelease } = useSessionActions(); const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions(); const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false); const [releasePending, setReleasePending] = useState(false);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape'; const autoClaimSocketRef = useRef(null);
const routeExitReleaseTimerRef = useRef(null);
const participantRef = useRef(false);
const closingThroughButtonRef = useRef(false);
const 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 () => { const releaseAndClose = useCallback(async () => {
if (releasePending) return; if (releasePending) return;
setReleasePending(true); setReleasePending(true);
closingThroughButtonRef.current = true;
try { try {
/* /*
Stop first so a held key/pointer cannot leave ONVIF continuous movement Stop first so a held key/pointer cannot leave ONVIF continuous movement
running while the server removes this socket from the PTZ queue. running while the server removes this socket from the PTZ queue.
*/ */
stopAllMotion?.(); stopAllMotion?.();
await ptzRelease(); if (ptz?.isOperator || ptz?.queuedPosition) {
onClose?.(); 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 { } finally {
setReleasePending(false); setReleasePending(false);
} }
}, [onClose, ptzRelease, releasePending, stopAllMotion]); }, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
if (!open) return null; if (!featureEnabled) {
return (
<main className="flex min-h-[100dvh] items-center justify-center 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 = ( return (
/* <main className="h-[100dvh] w-full overflow-hidden bg-black text-slate-100">
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">
<CardFrame <CardFrame
title={isMobile ? '' : ptz?.name || 'PTZ Camera'} title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
actions={isMobile ? null : ( actions={isMobile ? null : (
@@ -681,20 +850,17 @@ export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
bodyClassName="relative min-h-0 flex-1" bodyClassName="relative min-h-0 flex-1"
> >
{isMobile ? ( {isMobile ? (
<PtzMobileFullscreen layout === 'mobile-landscape' ? (
ptz={ptz} <PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
layout={layout} ) : (
onClose={releaseAndClose} <PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
releasePending={releasePending} )
/>
) : ( ) : (
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} /> <PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
)} )}
</CardFrame> </CardFrame>
</div> </main>
); );
return createPortal(controller, document.body);
} }
export default function PtzQueueCard({ layout = 'desktop' }) { export default function PtzQueueCard({ layout = 'desktop' }) {
@@ -704,10 +870,10 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
const role = useSessionSelector((state) => state.session?.role || null); const role = useSessionSelector((state) => state.session?.role || null);
const selfId = useSessionSelector((state) => state.session?.socketId || null); const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, ptzRelease } = useSessionActions(); const { ptzClaim, ptzRelease } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz); const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz); const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const now = useSharedClock(1000, Boolean(ptz?.deadline)); const now = useSharedClock(1000, Boolean(ptz?.deadline));
const [controllerOpen, setControllerOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown'); const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition); const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
@@ -718,7 +884,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
const handleRequest = async () => { const handleRequest = async () => {
if (!canUse || pending) return; if (!canUse || pending) return;
if (isParticipant) { if (isParticipant) {
setControllerOpen(true); navigate('/ptz');
return; return;
} }
setPending(true); setPending(true);
@@ -731,7 +897,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
dock-guard rejection does not strand the user in fullscreen. dock-guard rejection does not strand the user in fullscreen.
*/ */
if (response?.state?.isOperator || response?.state?.queuedPosition) { if (response?.state?.isOperator || response?.state?.queuedPosition) {
setControllerOpen(true); navigate('/ptz');
} }
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' }); trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
} catch (err) { } catch (err) {
@@ -767,8 +933,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
: 'request'; : 'request';
return ( return (
<> <CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
<ul className="space-y-0.5 text-sm"> <ul className="space-y-0.5 text-sm">
<QueueTargetRow <QueueTargetRow
target={{ target={{
@@ -806,8 +971,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
Verify your account to use the PTZ camera. Verify your account to use the PTZ camera.
</div> </div>
) : null} ) : null}
</CardFrame> </CardFrame>
<PtzFullscreenController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
</>
); );
} }
@@ -1,5 +1,5 @@
// Replay Ready Popup // Replay Ready Popup
// Purpose: Presents the latest Discord-hosted replay video to web users as soon as upload completes. // Purpose: Presents the latest delivered replay video, whether Discord-hosted or served by the rover server.
// Scope: Owns the ephemeral modal shell, immediate video loading, and click-outside close behavior. // Scope: Owns the ephemeral modal shell, immediate video loading, and click-outside close behavior.
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
@@ -35,13 +35,16 @@ function selectedKeysEqual(left, right) {
return true; return true;
} }
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) { export default function ReplaySourcesPanel({
panelId = 'replay-sources',
fillHeight = false,
defaultSelectedKey = null,
}) {
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []); const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []); const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null); const replayState = useSessionSelector((state) => state.session?.replay || null);
const latestReplay = useSessionSelector((state) => state.latestReplay);
const { triggerReplay } = useSessionActions(); const { triggerReplay } = useSessionActions();
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]); const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {}); const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
@@ -65,20 +68,35 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const activeReplayJob = useSessionSelector((state) => ( const activeReplayJob = useSessionSelector((state) => (
activeJobId ? state.replayJobs?.[activeJobId] || null : null activeJobId ? state.replayJobs?.[activeJobId] || null : null
)); ));
const latestReplayJobId = latestReplay?.jobId || null; // The job id is deliberately local to this mounted panel. Reading the global
// latestReplay value here caused a newly mounted panel to resurrect the last
// replay popup even though this panel did not request it. The job record can
// remain in shared session state for asynchronous socket updates; selecting
// it through this panel-owned id keeps popup ownership and lifetime local.
const panelReplay = activeReplayJob?.media || null;
const panelReplayJobId = panelReplay?.jobId || null;
const showPanelReplay = Boolean( const showPanelReplay = Boolean(
latestReplay?.url && panelReplay?.url &&
latestReplayJobId && panelReplayJobId &&
dismissedPanelReplayId !== latestReplayJobId, dismissedPanelReplayId !== panelReplayJobId,
); );
const defaults = useMemo(() => { const availableDefaultKey = useMemo(() => {
const roverId = assignmentRoverId; // PTZ layouts provide their camera key explicitly so entering the dedicated
if (roverId) { // camera page does not inherit the user's assigned rover. Waiting until the
return [`rover:${roverId}`]; // source is actually advertised also handles the initial session load: an
// unavailable key is never left selected, but it becomes the default as
// soon as the server publishes that replay source.
if (defaultSelectedKey && sources.some((source) => source.key === defaultSelectedKey)) {
return defaultSelectedKey;
} }
return []; const roverKey = assignmentRoverId ? `rover:${assignmentRoverId}` : null;
}, [assignmentRoverId]); if (roverKey && sources.some((source) => source.key === roverKey)) {
return roverKey;
}
return null;
}, [assignmentRoverId, defaultSelectedKey, sources]);
const defaults = useMemo(() => (availableDefaultKey ? [availableDefaultKey] : []), [availableDefaultKey]);
const defaultTitle = useMemo(() => { const defaultTitle = useMemo(() => {
const roverId = assignmentRoverId || null; const roverId = assignmentRoverId || null;
@@ -225,9 +243,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
{showPanelReplay ? ( {showPanelReplay ? (
<div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2"> <div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2">
<ReplayReadyPopup <ReplayReadyPopup
replay={latestReplay} replay={panelReplay}
variant="floating-panel" variant="floating-panel"
onClose={() => setDismissedPanelReplayId(latestReplayJobId)} onClose={() => setDismissedPanelReplayId(panelReplayJobId)}
/> />
</div> </div>
) : null} ) : null}
@@ -261,6 +279,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
setTitleDirty(true); setTitleDirty(true);
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next })); saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
}} }}
onKeyDown={(event) => {
// Enter is the keyboard equivalent of clicking Replay. Ignore
// composition events so confirming an IME candidate cannot
// accidentally submit a replay before the title is complete.
// handleReplay remains the single authority for cooldown,
// lockdown, busy, and empty-source checks.
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
event.preventDefault();
handleReplay();
}}
placeholder={defaultTitle} placeholder={defaultTitle}
maxLength={120} maxLength={120}
/> />
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import RoomCameraPanel from '../RoomCameraPanel/index.jsx'; import RoomCameraPanel from '../RoomCameraPanel/index.jsx';
import KinectPanel from '../KinectPanel/index.jsx'; import KinectPanel from '../KinectPanel/index.jsx';
import BalanceBoardPanel from '../BalanceBoardPanel/index.jsx';
import HomeAssistantControls from '../HomeAssistantControls/index.jsx'; import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import SettingsPanel from '../SettingsPanel/index.jsx'; import SettingsPanel from '../SettingsPanel/index.jsx';
import HelpPanel from '../HelpPanel/index.jsx'; import HelpPanel from '../HelpPanel/index.jsx';
@@ -420,6 +421,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<div className={`flex flex-col ${themeGapClass}`}> <div className={`flex flex-col ${themeGapClass}`}>
<NeatoCard /> <NeatoCard />
<LiftCard /> <LiftCard />
<BalanceBoardPanel />
<BarcodeGamesPanel /> <BarcodeGamesPanel />
<OdometerPanel /> <OdometerPanel />
<ButtonBoxPanel /> <ButtonBoxPanel />
@@ -0,0 +1,38 @@
// Session Document Title
// Purpose: Uses the configured local inter-instance profile name as the browser tab title.
// Scope: Owns only the document title lifecycle; the static HTML title remains the fallback.
import { useEffect } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
const DEFAULT_DOCUMENT_TITLE = document.title;
export default function SessionDocumentTitle() {
const interInstanceEnabled = useSessionSelector((state) => Boolean(state.session?.interInstances?.enabled));
const interInstanceName = useSessionSelector((state) =>
String(state.session?.interInstances?.profile?.name || '').trim(),
);
useEffect(() => {
/*
The static title from index.html remains the source of truth until the
server confirms that inter-instance sharing is enabled and supplies a
usable profile name. This prevents the profile's loading state from
replacing the familiar fallback title with an empty or temporary value.
*/
if (!interInstanceEnabled || !interInstanceName) return undefined;
document.title = interInstanceName;
/*
Restore the static title when the synchronized profile disappears or the
feature is disabled. React also runs this cleanup during development's
StrictMode effect check, so the component never leaves a stale server name
behind when its session-derived conditions stop being true.
*/
return () => {
document.title = DEFAULT_DOCUMENT_TITLE;
};
}, [interInstanceEnabled, interInstanceName]);
return null;
}
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx'; import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx'; import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx'; import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function SpectateVideo({ export default function SpectateVideo({
roverId = null, roverId = null,
@@ -11,12 +12,24 @@ export default function SpectateVideo({
fitParent = false, fitParent = false,
layoutFormat = 'desktop', layoutFormat = 'desktop',
}) { }) {
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
state.session?.role === 'spectator' &&
state.session?.isLocalNetwork === false &&
state.session?.bandwidthSavings?.externalSpectatorVideo === 'snapshots',
);
/*
Server auth already denies external spectator WHEP when this policy is set,
but explicitly selecting snapshot mode avoids expected authorization denials
and keeps analytics focused on actual stream failures.
*/
const videoMode = isExternalSpectatorSnapshotOnly ? 'snapshot' : null;
return ( return (
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}> <div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}> <div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
<RoverMediaPlayer <RoverMediaPlayer
roverId={roverId} roverId={roverId}
label={label} label={label}
videoMode={videoMode}
/> />
<RoverDescriptionOverlay <RoverDescriptionOverlay
roverId={roverId} roverId={roverId}
+16 -4
View File
@@ -326,9 +326,18 @@ function PtzControlReference() {
function PtzController({ open, onClose, layout = 'desktop' }) { function PtzController({ open, onClose, layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isOperator = Boolean(ptz?.isOperator); const isOperator = Boolean(ptz?.isOperator);
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape'; const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions(); const { ptzRelease } = useSessionActions();
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator }); /*
The VIP fullscreen surface is available to queued PTZ users too. Let queued
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 || !nonTurnSnapshotsActive;
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null; const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [releasePending, setReleasePending] = useState(false); const [releasePending, setReleasePending] = useState(false);
@@ -375,7 +384,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
<ChatPanel fillHeight title="Chat" /> <ChatPanel fillHeight title="Chat" />
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay" /> <ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<PtzStatePanel <PtzStatePanel
@@ -401,7 +410,10 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
<ChatPanel fillHeight title="Chat" /> <ChatPanel fillHeight title="Chat" />
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" /> <ReplaySourcesPanel
panelId="ptz-controller-replay-mobile"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
<PtzStatePanel <PtzStatePanel
@@ -431,7 +443,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`} bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
> >
<main className="relative min-h-0 min-w-0 bg-black"> <main className="relative min-h-0 min-w-0 bg-black">
{isOperator ? ( {shouldUseLiveVideo ? (
<PtzLiveVideo enabled startMuted={false} /> <PtzLiveVideo enabled startMuted={false} />
) : ( ) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} /> <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
+4
View File
@@ -410,6 +410,10 @@ export function SessionProvider({ children }) {
// maintenance without becoming a remote shell. // maintenance without becoming a remote shell.
updateRover: (roverId) => updateRover: (roverId) =>
emitWithAck('command', { roverId, type: 'update', data: { update: {} } }), emitWithAck('command', { roverId, type: 'update', data: { update: {} } }),
// Bulk rover updates stay server-owned so the admin browser sends one
// intent, then the server checks privileges and fans out the same fixed
// update command to every currently connected rover.
updateAllRovers: () => emitWithAck('command:updateAllRovers'),
rebootServer: () => emitWithAck('server:reboot'), rebootServer: () => emitWithAck('server:reboot'),
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) => playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }), emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
+18 -2
View File
@@ -168,7 +168,12 @@ export function ControlSystemProvider({ children }) {
: true; : true;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []); const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn)); const roomLightsLocked = useSessionSelector((state) =>
Boolean(state.session?.homeAssistant?.lightPolicy?.locked || state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
const roomLightsLockedOn = useSessionSelector((state) =>
Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
const { homeAssistantSetState } = useSessionActions(); const { homeAssistantSetState } = useSessionActions();
const overcurrentLimiter = useOvercurrentLimiter(roverId); const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback( const driveTransform = useCallback(
@@ -183,6 +188,17 @@ export function ControlSystemProvider({ children }) {
const ptzControls = usePtzControlAdapter(); const ptzControls = usePtzControlAdapter();
const turnOnAllLights = useCallback(() => { const turnOnAllLights = useCallback(() => {
/*
Automatic drive-mode lighting is convenience behavior for the open room.
A room-light lock is an explicit policy decision, including locked-off,
so this helper must not issue any Home Assistant commands while that
policy is active. Admins can still use the dedicated room controls when
they need to override individual lamps.
*/
if (roomLightsLocked) {
pendingLightsRef.current = false;
return;
}
const entities = homeAssistantEntities || []; const entities = homeAssistantEntities || [];
const targets = entities.filter( const targets = entities.filter(
(ent) => (ent) =>
@@ -200,7 +216,7 @@ export function ControlSystemProvider({ children }) {
}); });
// Give the loop a chance; clear pending after issuing commands. // Give the loop a chance; clear pending after issuing commands.
pendingLightsRef.current = false; pendingLightsRef.current = false;
}, [homeAssistantEntities, homeAssistantSetState]); }, [homeAssistantEntities, homeAssistantSetState, roomLightsLocked]);
useEffect(() => { useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId }); dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
@@ -108,6 +108,9 @@ export default function KeyboardInputManager() {
const roverId = useControlSelector((control) => control.state.roverId); const roverId = useControlSelector((control) => control.state.roverId);
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active)); const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null); const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const role = useSessionSelector((state) => state.session?.role || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const adminCanControlLockedLights = role === 'lockdown' || (role === 'admin' && mode !== 'lockdown');
const dockAssist = useManualDockAssist(); const dockAssist = useManualDockAssist();
const { homeAssistantSetState, pushAlert } = useSessionActions(); const { homeAssistantSetState, pushAlert } = useSessionActions();
const { focusChat } = useChatActions(); const { focusChat } = useChatActions();
@@ -310,7 +313,14 @@ export default function KeyboardInputManager() {
const latest = latestRef.current; const latest = latestRef.current;
const ha = latest?.homeAssistant; const ha = latest?.homeAssistant;
if (!ha?.enabled || !ha?.connected) return; if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return; /*
Room-light lock disables keyboard cycling for normal users because those
shortcuts are part of the public room-control surface. Admin sessions are
allowed through when the current site mode would also allow their socket
command, so keyboard behavior matches the server-side authorization and
the clickable Room Controls panel.
*/
if ((ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) && !latest?.adminCanControlLockedLights) return;
const entities = ha.entities || []; const entities = ha.entities || [];
const eligible = entities.filter( const eligible = entities.filter(
(ent) => (ent) =>
@@ -371,6 +381,7 @@ export default function KeyboardInputManager() {
homeAssistant, homeAssistant,
homeAssistantSetState, homeAssistantSetState,
isChatFocused, isChatFocused,
adminCanControlLockedLights,
keyboardSpeeds, keyboardSpeeds,
keymap, keymap,
nudgeServo, nudgeServo,
+3 -3
View File
@@ -11,9 +11,9 @@ export const OVERCURRENT_GROUPS = [
]; ];
export const DEFAULT_OVERCURRENT_LIMITS = { export const DEFAULT_OVERCURRENT_LIMITS = {
downRatePerSec: 0.5, downRatePerSec: 0.4,
upRatePerSec: 0.7, upRatePerSec: 0.5,
releaseDelaySec: 1, releaseDelaySec: 2.5,
outputRateMs: 250, outputRateMs: 250,
}; };
+58 -10
View File
@@ -4,7 +4,7 @@
// mobile, desktop, and gamepad inputs do not each learn camera-specific rules. // mobile, desktop, and gamepad inputs do not each learn camera-specific rules.
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
import { useSessionSelector } from '../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 }; const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 };
const PTZ_SPEEDS = { const PTZ_SPEEDS = {
@@ -98,9 +98,12 @@ function nextIrMode(currentMode) {
export function usePtzControlAdapter() { export function usePtzControlAdapter() {
const socket = useSocket(); const socket = useSocket();
const { ptzSpotlight, ptzIr } = useSessionActions();
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isActive = Boolean(ptz?.isOperator); const isActive = Boolean(ptz?.isOperator);
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP)); const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
const zoomIntentRef = useRef(0);
const zoomStopTimerRef = useRef(null); const zoomStopTimerRef = useRef(null);
const emitPtz = useCallback( const emitPtz = useCallback(
@@ -116,6 +119,10 @@ export function usePtzControlAdapter() {
clearTimeout(zoomStopTimerRef.current); clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null; 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); const stopSignature = payloadSignature(PTZ_STOP);
if (lastMotionSignatureRef.current === stopSignature) return; if (lastMotionSignatureRef.current === stopSignature) return;
lastMotionSignatureRef.current = stopSignature; lastMotionSignatureRef.current = stopSignature;
@@ -146,7 +153,20 @@ export function usePtzControlAdapter() {
const applyDriveVector = useCallback( const applyDriveVector = useCallback(
(vector, meta = {}) => { (vector, meta = {}) => {
if (!isActive) return false; if (!isActive) return false;
sendMotion(buildPanTiltPayload(vector, meta)); const panTilt = buildPanTiltPayload(vector, meta);
panTiltIntentRef.current = {
pan: panTilt.pan,
tilt: panTilt.tilt,
};
/*
ONVIF continuous movement accepts pan, tilt, and zoom in one command.
Preserve the current zoom intent when a direction update arrives so a
keyboard or touch event on one axis cannot erase another held axis.
*/
sendMotion({
...panTiltIntentRef.current,
zoom: zoomIntentRef.current,
});
return true; return true;
}, },
[isActive, sendMotion], [isActive, sendMotion],
@@ -157,7 +177,17 @@ export function usePtzControlAdapter() {
if (!isActive) return false; if (!isActive) return false;
const sign = axisSign(direction); const sign = axisSign(direction);
if (!sign) { 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; return true;
} }
/* /*
@@ -166,7 +196,11 @@ export function usePtzControlAdapter() {
the payload is identical, otherwise holding "camera up" only sends the the payload is identical, otherwise holding "camera up" only sends the
first zoom command and every later nudge is de-duped away. 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); if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
/* /*
Existing rover camera controls are nudge/slider based, not hold-based. Existing rover camera controls are nudge/slider based, not hold-based.
@@ -175,21 +209,31 @@ export function usePtzControlAdapter() {
*/ */
zoomStopTimerRef.current = setTimeout(() => { zoomStopTimerRef.current = setTimeout(() => {
zoomStopTimerRef.current = null; 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); }, ZOOM_PULSE_MS);
return true; return true;
}, },
[isActive, sendMotion, stopMotion], [isActive, sendMotion],
); );
const setSpotlight = useCallback( const setSpotlight = useCallback(
(nextOn) => { (nextOn) => {
if (!isActive) return false; if (!isActive) return false;
const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light); const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light);
emitPtz('ptzCamera:spotlight', { state: desiredOn ? 1 : 0 }); /*
Lighting keybinds should use the exact acknowledged command action as
the visible PTZ buttons. Movement remains fire-and-forget because it is
continuous and high frequency, but a discrete light toggle benefits
from the existing authorization/error contract and must not maintain a
second socket-only behavior merely because its source is a keybind.
*/
ptzSpotlight({ state: desiredOn ? 1 : 0 }).catch(() => {});
return true; return true;
}, },
[emitPtz, isActive, ptz?.light], [isActive, ptz?.light, ptzSpotlight],
); );
const setIr = useCallback( const setIr = useCallback(
@@ -198,15 +242,19 @@ export function usePtzControlAdapter() {
const desiredState = typeof nextOn === 'boolean' const desiredState = typeof nextOn === 'boolean'
? (nextOn ? 'On' : 'Off') ? (nextOn ? 'On' : 'Off')
: nextIrMode(ptz?.ir?.state); : nextIrMode(ptz?.ir?.state);
emitPtz('ptzCamera:ir', { state: desiredState }); // Match the button path for the same reason as spotlight above. The
// shared laser key continues to select IR; only its transport is unified.
ptzIr({ state: desiredState }).catch(() => {});
return true; return true;
}, },
[emitPtz, isActive, ptz?.ir?.state], [isActive, ptz?.ir?.state, ptzIr],
); );
useEffect(() => { useEffect(() => {
if (isActive) return undefined; if (isActive) return undefined;
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP); lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
panTiltIntentRef.current = { pan: 0, tilt: 0 };
zoomIntentRef.current = 0;
if (zoomStopTimerRef.current) { if (zoomStopTimerRef.current) {
clearTimeout(zoomStopTimerRef.current); clearTimeout(zoomStopTimerRef.current);
zoomStopTimerRef.current = null; zoomStopTimerRef.current = null;
@@ -11,6 +11,7 @@ import OnlinePeopleStrip from './components/OnlinePeopleStrip.jsx';
import DisplayRoverGrid from './components/DisplayRoverGrid.jsx'; import DisplayRoverGrid from './components/DisplayRoverGrid.jsx';
import DisplayChatFeed from './components/DisplayChatFeed.jsx'; import DisplayChatFeed from './components/DisplayChatFeed.jsx';
import DisplayNoticeOverlay from './components/DisplayNoticeOverlay.jsx'; import DisplayNoticeOverlay from './components/DisplayNoticeOverlay.jsx';
import DisplayPtzOperatorBadge from './components/DisplayPtzOperatorBadge.jsx';
export default function ServerDisplayContent() { export default function ServerDisplayContent() {
const { session } = useSession(); const { session } = useSession();
@@ -35,8 +36,14 @@ export default function ServerDisplayContent() {
return ( return (
<div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100"> <div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100">
<div className="h-[8vh] min-h-[4rem] shrink-0"> <div className="flex h-[8vh] min-h-[4rem] shrink-0 overflow-hidden">
<OnlinePeopleStrip users={session?.users || []} /> <OnlinePeopleStrip users={session?.users || []} />
{/* The PTZ operator belongs in the same information band as the people
strip because it is another "who is active right now" signal. Making
it a flex sibling lets the badge reserve real layout space when it
appears, which pushes the scrolling strip left instead of covering
the rover or chat areas. */}
<DisplayPtzOperatorBadge />
</div> </div>
<div className="min-h-0 flex-[0.72]"> <div className="min-h-0 flex-[0.72]">
<DisplayRoverGrid roster={session?.roster || []} session={session} /> <DisplayRoverGrid roster={session?.roster || []} session={session} />
@@ -0,0 +1,39 @@
// Display PTZ Operator Badge
// Purpose: Gives the physical /display page a simple, always-readable indicator
// for who currently owns the single PTZ camera turn.
// Scope: This is intentionally display-only chrome; PTZ ownership, naming, and
// permission rules stay in the server session state that every client already receives.
import { useSessionSelector } from '../../../context/SessionContext.jsx';
export default function DisplayPtzOperatorBadge() {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const operatorLabel = String(ptz?.operatorLabel || '').trim();
const visible = Boolean(ptz?.enabled && operatorLabel);
return (
<aside
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}
>
{/*
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="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 ( return (
<div ref={viewportRef} className="relative h-full min-w-0 overflow-hidden border-b border-slate-800/80 bg-black"> <div
ref={viewportRef}
className="relative h-full min-w-0 flex-1 overflow-hidden border-b border-slate-800/80 bg-black"
>
<div <div
ref={trackRef} ref={trackRef}
className={classNames( className={classNames(
+10 -16
View File
@@ -4,11 +4,12 @@ import { useSharedClock } from './useSharedClock.js';
export function useDriverVideoModePolicy(roverId) { export function useDriverVideoModePolicy(roverId) {
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {}); const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null); const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {}); const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const isTurnsMode = mode === 'turns'; const isTurnsMode = mode === 'turns';
/* /*
This policy only switches preview/full video around a multi-second turn This policy only switches preview/full video around a multi-second turn
@@ -30,20 +31,13 @@ export function useDriverVideoModePolicy(roverId) {
const isNextDriver = Boolean(socketId && nextDriverId === socketId); const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null; const deadline = turnInfo?.deadline || null;
const msUntilTurn = deadline ? deadline - now : null; const msUntilTurn = deadline ? deadline - now : null;
const totalRovers = roster.length; /*
const totalDrivers = useMemo(() => { The server evaluates the global controllable-user threshold because that
const unique = new Set(); same decision is enforced in socket video tokens and MediaMTX auth. This
users.forEach((entry) => { hook only mirrors the active result so the browser does not request live
const role = String(entry?.role || ''); video when snapshots are already the authoritative non-turn outcome.
if (role === 'spectator') return; */
const turnRoverId = String(entry?.roverId || '').trim(); const shouldUsePreviewByLoad = nonTurnSnapshotsActive && isTurnsMode;
const turnSocketId = String(entry?.socketId || '').trim();
if (!turnRoverId || !turnSocketId) return;
unique.add(turnSocketId);
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow = const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0; isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = isTurnsMode && !isActiveDriver; const showNotTurnNotice = isTurnsMode && !isActiveDriver;
+33
View File
@@ -0,0 +1,33 @@
// Responsive Layout Mode Hook
// Purpose: Gives control-capable routes the same desktop, mobile-landscape,
// and mobile-portrait breakpoint policy.
// Scope: Classifies viewport geometry only; each route still owns its actual
// component arrangement so PTZ and rover controls can remain purpose-built.
import { useEffect, useState } from 'react';
function readLayoutMode() {
if (typeof window === 'undefined') return 'desktop';
if (window.innerWidth >= 1024) return 'desktop';
return window.innerWidth > window.innerHeight ? 'mobile-landscape' : 'mobile-portrait';
}
export default function useLayoutMode() {
const [mode, setMode] = useState(readLayoutMode);
useEffect(() => {
function updateMode() {
/*
Orientation changes are exposed as viewport resizes on the browsers
supported by this UI. Reading both dimensions here keeps the route
responsive without maintaining a second orientation event lifecycle.
*/
setMode(readLayoutMode());
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
+11 -1
View File
@@ -6,6 +6,7 @@ import { useSession } from '../context/SessionContext.jsx';
export function useSpectatorMode() { export function useSpectatorMode() {
const { session, setRole, subscribeAll, connected } = useSession(); const { session, setRole, subscribeAll, connected } = useSession();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const canUseSpectatorAccess = session?.bandwidthSavings?.canUseExternalSpectatorAccess !== false;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -14,6 +15,15 @@ export function useSpectatorMode() {
setReady(false); setReady(false);
return; return;
} }
if (!canUseSpectatorAccess) {
/*
The server will reject the role change too, but stopping here prevents
a blocked external spectator from retrying on every session sync while
the page is intentionally waiting for an admin grant or config change.
*/
setReady(false);
return;
}
try { try {
if (session?.role !== 'spectator') { if (session?.role !== 'spectator') {
await setRole('spectator'); await setRole('spectator');
@@ -33,7 +43,7 @@ export function useSpectatorMode() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [connected, session?.mode, session?.role, setRole, subscribeAll]); }, [canUseSpectatorAccess, connected, session?.mode, session?.role, setRole, subscribeAll]);
return ready; return ready;
} }
+10
View File
@@ -17,11 +17,14 @@ import DatabaseAdminApp from './database/DatabaseAdminApp.jsx'
import { SettingsProvider } from './settings/index.js' import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx' import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx' import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
<SessionDocumentTitle />
<TelemetryProvider> <TelemetryProvider>
<SettingsProvider> <SettingsProvider>
<ChatProvider> <ChatProvider>
@@ -35,6 +38,13 @@ createRoot(document.getElementById('root')).render(
<Route path="/display" element={<ServerDisplayApp />} /> <Route path="/display" element={<ServerDisplayApp />} />
<Route path="/scanner" element={<ScannerApp />} /> <Route path="/scanner" element={<ScannerApp />} />
<Route path="/database" element={<DatabaseAdminApp />} /> <Route path="/database" element={<DatabaseAdminApp />} />
{/*
PTZ is a separate route so the driver layout and its replay
panel are not mounted behind the camera controller. This
also makes orientation changes a PTZ layout concern instead
of a local overlay-open state owned by the driver page.
*/}
<Route path="/ptz" element={<PtzAppRoot />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ChatProvider> </ChatProvider>
@@ -1,7 +1,7 @@
// Mini Summary Content // Mini Summary Content
// Purpose: Defines the Mini Summary Content module and the local helpers/components used in this file. // Purpose: Defines the Mini Summary Content module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSession } from '../../context/SessionContext.jsx';
import { useVisualTelemetryFrames } from '../../context/TelemetryContext.jsx'; import { useVisualTelemetryFrames } from '../../context/TelemetryContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js'; import { useVideoRequests } from '../../hooks/useVideoRequests.js';
@@ -9,6 +9,7 @@ import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js'; import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js'; import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js'; import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx'; import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx'; import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx'; import InfoColumn from './components/InfoColumn.jsx';
@@ -21,6 +22,133 @@ function hasRoverAudioCapture(rover) {
return Boolean(rover?.media?.audioCapture?.enabled && rover?.media?.audioCapture?.publishUrl); return Boolean(rover?.media?.audioCapture?.enabled && rover?.media?.audioCapture?.publishUrl);
} }
function AutoFitBoxText({ children, className = '', maxSize = 1000, minSize = 14 }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let raf = null;
const fit = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) {
scheduleFit();
return;
}
/*
Unlike the shared AutoFitText helper, the PTZ operator label has a real
second constraint: before rotation, text length must fit the screen
height and text thickness must fit the blue column width. Binary search
lets the name use as much of both dimensions as possible without
clipping either after the 90-degree transform.
*/
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
textEl.style.fontSize = `${mid}px`;
const fits = textEl.scrollWidth <= width && textEl.scrollHeight <= height;
if (fits) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(fit);
};
scheduleFit();
const ro = new ResizeObserver(scheduleFit);
ro.observe(container);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, [children, maxSize, minSize]);
return (
<div ref={containerRef} className="flex h-full w-full min-w-0 items-center justify-center overflow-hidden">
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1 }}
>
{children}
</div>
</div>
);
}
function MiniPtzUserColumn({ label }) {
const columnRef = useRef(null);
const [columnSize, setColumnSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const column = columnRef.current;
if (!column) return undefined;
const updateSize = () => {
/*
The label box is measured before rotation, so the blue column's screen
height becomes the label's unrotated width, and the blue column's screen
width becomes the label's unrotated height. That swapped geometry is the
key difference from normal horizontal auto-fit text.
*/
setColumnSize({
width: column.clientWidth,
height: column.clientHeight,
});
};
updateSize();
const ro = new ResizeObserver(updateSize);
ro.observe(column);
return () => ro.disconnect();
}, []);
return (
<div ref={columnRef} className="relative flex h-full w-full min-w-0 items-center justify-center overflow-hidden bg-sky-600 text-white">
{/*
The mini page is intended for glanceable room displays, so the PTZ user
gets the same right-side identity treatment as rover metadata while
letting the blue column absorb any extra horizontal room after the
full-height PTZ video takes the camera-shaped part of the viewport.
*/}
{columnSize.width > 16 && columnSize.height > 16 ? (
<div
className="absolute left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2 rotate-90 px-2 text-center leading-none"
style={{
width: `${columnSize.height - 16}px`,
height: `${columnSize.width - 16}px`,
}}
>
{/*
This fitter checks both text width and text height before rotation.
After rotation, that means the username fits both the vertical
reading length and the visible thickness of the blue column.
*/}
<AutoFitBoxText className="font-black leading-none text-white" maxSize={1000} minSize={16}>
{label}
</AutoFitBoxText>
</div>
) : null}
</div>
);
}
export default function MiniSummaryContent() { export default function MiniSummaryContent() {
const { session } = useSession(); const { session } = useSession();
const spectatorReady = useSpectatorMode(); const spectatorReady = useSpectatorMode();
@@ -33,24 +161,30 @@ export default function MiniSummaryContent() {
const canSpectateVideo = Boolean(session?.isLocalNetwork); const canSpectateVideo = Boolean(session?.isLocalNetwork);
const frames = useVisualTelemetryFrames(); const frames = useVisualTelemetryFrames();
const roster = session?.roster ?? []; const roster = session?.roster ?? [];
const ptz = session?.ptzCamera || null;
const ptzOperatorLabel = String(ptz?.operatorLabel || '').trim();
const hasActivePtzOperator = Boolean(ptz?.enabled && ptzOperatorLabel);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const activeDrivers = session?.activeDrivers || {}; const activeDrivers = session?.activeDrivers || {};
const driverRoster = useMemo( const driverRoster = useMemo(
() => roster.filter((rover) => activeDrivers[rover.id]), () => roster.filter((rover) => activeDrivers[rover.id]),
[roster, activeDrivers], [roster, activeDrivers],
); );
const snapshotRoster = driverRoster.length ? driverRoster : roster; const mediaRovers = useMemo(
() => roster.filter((rover) => rover?.id),
[roster],
);
const snapshotFeeds = useRoverSnapshots( const snapshotFeeds = useRoverSnapshots(
snapshotRoster.map((rover) => rover.id), mediaRovers.map((rover) => rover.id),
{ enabled: !inLockdown && !canSpectateVideo, version: session?.mode }, { enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
); );
const videoEntries = useMemo( const videoEntries = useMemo(
() => () =>
canSpectateVideo canSpectateVideo
? snapshotRoster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id })) ? mediaRovers.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
: [], : [],
[canSpectateVideo, snapshotRoster], [canSpectateVideo, mediaRovers],
); );
const videoSources = useVideoRequests(videoEntries, { const videoSources = useVideoRequests(videoEntries, {
enabled: !inLockdown && canSpectateVideo, enabled: !inLockdown && canSpectateVideo,
@@ -67,27 +201,23 @@ export default function MiniSummaryContent() {
); );
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode }); const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
const roverPool = useMemo(() => {
if (!driverRoster.length) return [];
if (!canSpectateVideo) {
const withSnapshot = driverRoster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
return withSnapshot.length ? withSnapshot : driverRoster;
}
const withVideo = driverRoster.filter((rover) => {
const sessionInfo = videoSources[rover.id];
return sessionInfo?.url && !sessionInfo?.error;
});
return withVideo.length ? withVideo : driverRoster;
}, [driverRoster, snapshotFeeds, videoSources, canSpectateVideo]);
const rotationPool = useMemo(() => { const rotationPool = useMemo(() => {
return roverPool.map((rover) => ({ type: 'rover', rover })); /*
}, [roverPool]); Rotation membership is intentionally based on human-controlled sources,
not on WHEP response timing. The mounted media players below are a
separate full-roster list, so a video token refresh, snapshot arrival, or
active-driver update changes which already-warm layer is visible instead
of tearing down the player components themselves.
*/
const entries = driverRoster.map((rover) => ({ type: 'rover', rover }));
if (hasActivePtzOperator) entries.push({ type: 'ptz' });
return entries;
}, [driverRoster, hasActivePtzOperator]);
const rotationKey = useMemo( const rotationKey = useMemo(
() => () =>
rotationPool rotationPool
.map((entry) => `r:${entry.rover.id}`) .map((entry) => (entry.type === 'ptz' ? 'p:ptz-camera' : `r:${entry.rover.id}`))
.join('|'), .join('|'),
[rotationPool], [rotationPool],
); );
@@ -106,12 +236,14 @@ export default function MiniSummaryContent() {
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null; const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null; const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
const activePtz = activeEntry?.type === 'ptz';
const activeSnapshot = !canSpectateVideo && activeRover ? snapshotFeeds[activeRover.id] || null : null;
const activeVideo = canSpectateVideo && activeRover ? videoSources[activeRover.id] || null : null; const activeVideo = canSpectateVideo && activeRover ? videoSources[activeRover.id] || null : null;
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null; const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
const activeFrame = activeRover ? frames[activeRover.id] || null : null; const activeFrame = activeRover ? frames[activeRover.id] || null : null;
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null; const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
const showSideBySideFallback = !driverRoster.length && !hasActivePtzOperator;
const hasMountedMediaSources = Boolean(mediaRovers.length || ptz?.enabled);
if (inLockdown) { if (inLockdown) {
return ( return (
@@ -124,10 +256,10 @@ export default function MiniSummaryContent() {
); );
} }
if (!driverRoster.length) { return (
return ( <div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100"> {showSideBySideFallback ? (
<section className="flex h-full w-full gap-x-3 bg-slate-900 px-0"> <section className="absolute inset-0 z-30 flex h-full w-full gap-x-3 bg-slate-900 px-0">
{roster.length ? ( {roster.length ? (
roster.map((rover) => ( roster.map((rover) => (
<InfoColumn <InfoColumn
@@ -148,34 +280,50 @@ export default function MiniSummaryContent() {
</div> </div>
)} )}
</section> </section>
</div> ) : null}
);
}
return (
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section <section
className="relative flex h-full shrink-0 items-center justify-start overflow-hidden bg-black" className={`relative flex h-full shrink-0 items-center justify-start overflow-hidden bg-black ${
style={{ width: 'min(100vw, calc(100vh * 4 / 3))' }} /*
The no-driver view is still visually the old side-by-side roster,
but keeping this carousel layer mounted behind it prevents every
rover WHEP player from cold-starting when the first driver appears.
*/
showSideBySideFallback ? 'opacity-0 pointer-events-none' : ''
}`}
style={{
/*
Rover cameras are framed by the existing 4:3 viewport helper below,
but the PTZ camera is a 16:9 source. Give PTZ a 16:9 full-height
media lane here so the actual camera image reaches the top and
bottom of the screen, then let the right-side blue column consume
whatever width remains.
*/
width: activePtz ? 'min(100vw, calc(100vh * 16 / 9))' : 'min(100vw, calc(100vh * 4 / 3))',
}}
> >
{!spectatorReady ? ( {!spectatorReady ? (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500"> <div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
Switching to spectator Switching to spectator
</div> </div>
) : activeRover ? ( ) : hasMountedMediaSources ? (
<FitViewportFrame> <div className="relative h-full w-full overflow-hidden bg-black">
{canSpectateVideo ? ( {/*
<div className="relative h-full w-full"> Keep every mini media source mounted while the carousel rotates.
{rotationPool.map((entry) => { WHEP negotiation is the expensive part that caused the visible
const rover = entry.rover; blank gap, so inactive sources are hidden with opacity instead of
const isActive = activeRover?.id === rover.id; being removed from React's tree and forced to reconnect later.
return ( */}
<div {mediaRovers.map((rover) => {
key={rover.id} const isActive = activeRover?.id === rover.id;
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} return (
> <div
key={rover.id}
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<FitViewportFrame>
{canSpectateVideo ? (
<RoverMediaPlayer <RoverMediaPlayer
sessionInfo={videoSources[rover.id] || null} roverId={rover.id}
videoMode="whep" videoMode="whep"
snapshotFeed={null} snapshotFeed={null}
audioSessionInfo={isActive ? activeAudio : null} audioSessionInfo={isActive ? activeAudio : null}
@@ -183,29 +331,53 @@ export default function MiniSummaryContent() {
label={rover.name || rover.id} label={rover.name || rover.id}
sensors={frames[rover.id]?.sensors || null} sensors={frames[rover.id]?.sensors || null}
/> />
</div> ) : (
); <RoverMediaPlayer
})} sessionInfo={null}
videoMode="snapshot"
snapshotFeed={snapshotFeeds[rover.id] || null}
audioSessionInfo={isActive ? activeAudio : null}
forceMute={!isActive}
label={rover.name || rover.id}
sensors={frames[rover.id]?.sensors || null}
/>
)}
</FitViewportFrame>
</div>
);
})}
{ptz?.enabled ? (
<div className={`absolute inset-0 ${activePtz ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
{/*
PTZ bypasses FitViewportFrame because that helper intentionally
enforces the rover camera's 4:3 shape. Keeping this component
mounted preserves the PTZ WHEP session across rover/PTZ
rotations while still letting the visible pane resize to 16:9
when PTZ becomes active.
*/}
<PtzLiveVideo
enabled={!inLockdown}
startMuted
className="relative h-full w-full bg-black"
videoClassName="h-full w-full object-cover"
/>
</div> </div>
) : ( ) : null}
<RoverMediaPlayer </div>
sessionInfo={null}
videoMode="snapshot"
snapshotFeed={activeSnapshot}
audioSessionInfo={activeAudio}
label={activeRover.name || activeRover.id}
sensors={activeFrame?.sensors || null}
/>
)}
</FitViewportFrame>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500"> <div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
{driverRoster.length ? 'No sources available.' : 'No active drivers.'} {driverRoster.length ? 'No sources available.' : 'No active drivers.'}
</div> </div>
)} )}
</section> </section>
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-black"> <aside
{activeRover ? ( className={`flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-black ${
showSideBySideFallback ? 'opacity-0 pointer-events-none' : ''
}`}
>
{activePtz ? (
<MiniPtzUserColumn label={ptzOperatorLabel} />
) : activeRover ? (
<InfoColumn <InfoColumn
rover={activeRover} rover={activeRover}
frame={activeFrame} frame={activeFrame}
+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 />;
}
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js'; import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import { useSettingsNamespace } from '../../settings/index.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js'; import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js'; import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
import ChatPanel from '../../components/ChatPanel/index.jsx'; import ChatPanel from '../../components/ChatPanel/index.jsx';
@@ -17,12 +18,26 @@ import usePortraitLayout from './hooks/usePortraitLayout.js';
import RoverRow from './components/RoverRow.jsx'; import RoverRow from './components/RoverRow.jsx';
import SecondaryRow from './components/SecondaryRow.jsx'; import SecondaryRow from './components/SecondaryRow.jsx';
import LogsRow from './components/LogsRow.jsx'; import LogsRow from './components/LogsRow.jsx';
import SpectatorViewControls from './components/SpectatorViewControls.jsx';
const SPECTATOR_VIEW_DEFAULTS = {
showSidebar: true,
showRovers: true,
showPtz: true,
showRoomCameras: true,
};
export default function SpectatorContent() { export default function SpectatorContent() {
const { session } = useSession(); const { session } = useSession();
const latestReplay = useSessionSelector((state) => state.latestReplay); const latestReplay = useSessionSelector((state) => state.latestReplay);
const { clearLatestReplay } = useSessionActions(); const { clearLatestReplay } = useSessionActions();
const { value: viewPreferences, save: saveViewPreferences } = useSettingsNamespace(
'spectatorPage',
SPECTATOR_VIEW_DEFAULTS,
);
const inLockdown = session?.mode === 'lockdown'; const inLockdown = session?.mode === 'lockdown';
const canUseSpectatorAccess = session?.bandwidthSavings?.canUseExternalSpectatorAccess !== false;
const spectatorAccessMode = session?.bandwidthSavings?.externalSpectatorAccess || 'on';
useDefaultNickname(); useDefaultNickname();
// The spectator route is not rendered through App.jsx, so it must opt into // The spectator route is not rendered through App.jsx, so it must opt into
// the same persisted identity heartbeat here. That keeps the existing // the same persisted identity heartbeat here. That keeps the existing
@@ -32,6 +47,15 @@ export default function SpectatorContent() {
useSpectatorMode(); useSpectatorMode();
const isPortraitLayout = usePortraitLayout(); const isPortraitLayout = usePortraitLayout();
const roster = session?.roster ?? []; const roster = session?.roster ?? [];
// Treat absent keys as enabled so older settings cookies gain every new view
// option by default instead of unexpectedly hiding parts of the page.
const showSidebar = viewPreferences?.showSidebar !== false;
const showRovers = viewPreferences?.showRovers !== false;
const showPtz = viewPreferences?.showPtz !== false;
const showRoomCameras = viewPreferences?.showRoomCameras !== false;
const updateViewPreference = (key, enabled) => {
saveViewPreferences((current) => ({ ...(current || {}), [key]: Boolean(enabled) }));
};
if (inLockdown) { if (inLockdown) {
return ( return (
@@ -44,9 +68,33 @@ export default function SpectatorContent() {
); );
} }
if (session && !canUseSpectatorAccess) {
/*
The server keeps this socket in the normal user role when external
spectating is blocked. Rendering a full-page gate makes that intentional
state obvious instead of showing an empty spectator shell that repeatedly
fails to subscribe.
*/
const message = spectatorAccessMode === 'admin'
? 'This external spectator identity needs admin approval before it can view the spectator page.'
: spectatorAccessMode === 'verifiedOnly'
? 'This external spectator identity must be verified before it can view the spectator page.'
: 'External spectator access is disabled on this server.';
return (
<div className="flex min-h-screen items-center justify-center bg-black text-slate-200">
<div className="surface max-w-md space-y-0.5 p-1 text-center text-sm">
<p className="text-lg font-semibold text-white">Spectate access required.</p>
<p className="text-slate-300">{message}</p>
</div>
</div>
);
}
const mainClass = isPortraitLayout const mainClass = isPortraitLayout
? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden' ? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden'
: 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]'; : showSidebar
? 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]'
: 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0';
const contentClass = isPortraitLayout const contentClass = isPortraitLayout
? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto' ? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto'
: 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto'; : 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto';
@@ -59,7 +107,7 @@ export default function SpectatorContent() {
return ( return (
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden"> <div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
<main className={mainClass}> <main className={mainClass}>
<section className={sidebarClass}> {showSidebar ? <section className={sidebarClass}>
{isPortraitLayout ? ( {isPortraitLayout ? (
<div className={`${topBarItemClass} ${portraitItemHeight} flex flex-col gap-0.5`}> <div className={`${topBarItemClass} ${portraitItemHeight} flex flex-col gap-0.5`}>
<GlobalObjectiveBanner layout="desktop" dismissable={false} className="text-sm" /> <GlobalObjectiveBanner layout="desktop" dismissable={false} className="text-sm" />
@@ -90,12 +138,20 @@ export default function SpectatorContent() {
<div className={`${topBarItemClass} ${isPortraitLayout ? portraitItemHeight : ''}`}> <div className={`${topBarItemClass} ${isPortraitLayout ? portraitItemHeight : ''}`}>
<LogsRow className={`${isPortraitLayout ? 'h-full' : 'h-40'} overflow-hidden`} /> <LogsRow className={`${isPortraitLayout ? 'h-full' : 'h-40'} overflow-hidden`} />
</div> </div>
</section> </section> : null}
<section className={contentClass}> <section className={contentClass}>
<RoverRow roster={roster} /> {/*
<SecondaryRow /> Conditional rendering is deliberate: CSS-only hiding would leave
WHEP peer connections and snapshot socket subscriptions active.
Unmounting delegates cleanup to the media components that own them.
*/}
{showRovers || showPtz ? (
<RoverRow roster={roster} showRovers={showRovers} showPtz={showPtz} />
) : null}
{showRoomCameras ? <SecondaryRow /> : null}
</section> </section>
</main> </main>
<SpectatorViewControls preferences={viewPreferences} onToggle={updateViewPreference} />
<AlertFeed /> <AlertFeed />
<RewardRunOverlay /> <RewardRunOverlay />
{/* Spectators do not have the replay request panel that normal web users see, so {/* Spectators do not have the replay request panel that normal web users see, so
@@ -81,6 +81,19 @@ function PtzSnapshotFallback({ label, source }) {
} }
function PtzLiveOrSnapshot({ label }) { function PtzLiveOrSnapshot({ label }) {
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
state.session?.role === 'spectator' &&
state.session?.isLocalNetwork === false &&
state.session?.bandwidthSavings?.externalSpectatorVideo === 'snapshots',
);
/*
PTZ live authorization is server-owned, but the spectator page knows when
the configured outcome is snapshot-only. Rendering the fallback directly
avoids a guaranteed denied WHEP request for every external spectator card.
*/
if (isExternalSpectatorSnapshotOnly) {
return <PtzSnapshotFallback label={label} source={null} />;
}
return ( return (
<PtzLiveVideo <PtzLiveVideo
enabled enabled
@@ -4,14 +4,21 @@
import RoverSpectatorCard from './RoverSpectatorCard.jsx'; import RoverSpectatorCard from './RoverSpectatorCard.jsx';
import PtzSpectatorCard from './PtzSpectatorCard.jsx'; import PtzSpectatorCard from './PtzSpectatorCard.jsx';
export default function RoverRow({ roster }) { export default function RoverRow({ roster, showRovers = true, showPtz = true }) {
return ( return (
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2"> <section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
{roster.length === 0 ? <p className="col-span-full text-slate-400">No rovers registered.</p> : null} {showRovers && roster.length === 0 ? <p className="col-span-full text-slate-400">No rovers registered.</p> : null}
{roster.map((rover) => ( {showRovers
<RoverSpectatorCard key={rover.id} rover={rover} /> ? roster.map((rover) => (
))} <RoverSpectatorCard key={rover.id} rover={rover} />
<PtzSpectatorCard /> ))
: null}
{/*
PTZ intentionally remains the last grid item. With three rovers it
therefore occupies the fourth tile, preserving the spectator layout's
designed visual balance while still allowing independent unmounting.
*/}
{showPtz ? <PtzSpectatorCard /> : null}
</section> </section>
); );
} }
@@ -0,0 +1,82 @@
// Spectator View Controls
// Purpose: Lets each spectator choose which major page regions consume space and media bandwidth.
// Scope: Owns only the fixed gear menu UI; cookie persistence and layout decisions remain in SpectatorContent.
import { useEffect, useRef, useState } from 'react';
import { FaCog } from 'react-icons/fa';
import CardFrame from '../../../components/CardFrame/index.jsx';
const VIEW_OPTIONS = [
{ key: 'showSidebar', label: 'Sidebar' },
{ key: 'showRovers', label: 'Rovers' },
{ key: 'showPtz', label: 'PTZ camera' },
{ key: 'showRoomCameras', label: 'Room cameras' },
];
export default function SpectatorViewControls({ preferences, onToggle }) {
const [open, setOpen] = useState(false);
const controlsRef = useRef(null);
useEffect(() => {
if (!open) return undefined;
const handlePointerDown = (event) => {
/*
The popup floats above both the sidebar and camera grid, so clicking
elsewhere should dismiss it without changing any saved preference.
Keeping the gear and popup under one ref makes clicks on either safe.
*/
if (!controlsRef.current?.contains(event.target)) setOpen(false);
};
const handleKeyDown = (event) => {
// Escape mirrors normal dialog/menu behavior without trapping keyboard focus.
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
return (
<div ref={controlsRef} className="fixed right-1 top-1 z-50 flex flex-col items-end gap-0.5">
<button
type="button"
className="button-dark flex h-8 w-8 items-center justify-center p-0 text-slate-100 shadow-lg"
aria-label="Spectator view settings"
aria-expanded={open}
aria-controls="spectator-view-controls"
onClick={() => setOpen((current) => !current)}
>
<FaCog aria-hidden="true" />
</button>
{open ? (
<CardFrame
title="Spectator view"
className="w-48 shadow-xl"
bodyClassName="space-y-0.5 p-0.5 text-sm"
clipOverflow={false}
>
<div id="spectator-view-controls" className="space-y-0.5">
{VIEW_OPTIONS.map((option) => (
<label
key={option.key}
className="surface-muted flex cursor-pointer items-center gap-0.5 px-1 py-0.75 text-slate-100"
>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-sky-500"
checked={preferences[option.key] !== false}
onChange={(event) => onToggle(option.key, event.target.checked)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</CardFrame>
) : null}
</div>
);
}