Compare commits

...
137 Commits
Author SHA1 Message Date
legop3 f654394636 roverd tty alerts stuffs 2026-08-21 01:24:59 -04:00
legop3 c8836f7d65 rover server restart desync fix hopefully 2026-08-20 15:27:23 -04:00
legop3 f77a969098 loading screen, better HUD, etc. 2026-08-20 14:38:07 -04:00
legop3 5ab62c3633 bebahba 2026-08-19 21:35:02 -04:00
legop3 fab0673d9e many small improvements 2026-08-19 19:08:06 -04:00
legop3 a5ec1dcb2d fixing pod collapse expandings 2026-08-19 14:20:57 -04:00
legop3 6ff60f7d0e slop board 2026-08-19 00:10:41 -04:00
legop3 ea16e2c67a slop board 2026-08-18 23:56:26 -04:00
legop3 9615423e06 slop board 2026-08-18 23:51:19 -04:00
legop3 93232e2a54 balance board slopping 2026-08-18 23:36:38 -04:00
legop3 271197f33c ui race condition fix 2026-08-18 23:15:24 -04:00
legop3 8e7d31dcdd better dock resolving 2026-08-18 22:51:39 -04:00
legop3 4f87a0eec2 change theme gap back to smallers 2026-08-18 22:42:38 -04:00
legop3 cc2e85d174 assignment adjustments and ui tweakings 2026-08-18 22:36:42 -04:00
legop3 737760ff56 big boy webui new new new new new 100 files changed 80 years 2026-08-18 22:01:25 -04:00
legop3 0f0f82e5f6 newdrive planning 2026-08-17 02:54:01 -04:00
legop3 3807b8bb13 Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-08-14 23:22:28 -04:00
legop3 9f2f819bbd neato alerts and better commands 2026-08-14 23:22:27 -04:00
legop3 60329e1036 Enhance MIDI player task with detailed research points
Expanded on the first task to improve the MIDI player with detailed research points and objectives.
2026-08-14 23:11:04 -04:00
legop3 4430517af5 neato updates 2026-08-14 20:25:07 -04:00
legop3 b24d453ad1 redo and move rover ranking a little 2026-08-11 21:34:29 -04:00
legop3 b5e8d775a2 home assistant always turn lights on at full brightness trying to fix weird bulb 2026-08-11 21:25:16 -04:00
legop3 e5830533ba arm powered steam link ?? 2026-08-11 20:50:15 -04:00
legop3 dfd674a447 arm powered steam deck 2026-08-11 20:36:55 -04:00
legop3 bd65f93756 green adjustment 2026-08-09 00:31:11 -04:00
legop3 0083c887f4 fix 2026-08-09 00:13:38 -04:00
legop3 70f71b2d1e green mode slop 1 2026-08-08 23:59:47 -04:00
legop3 c8742dbbd6 slop planning 2026-08-08 00:20:08 -04:00
legop3 6a6dec5540 crocs 2026-08-07 23:25:56 -04:00
legop3 6c69c583c5 moving audio gain perms around 2026-08-07 22:34:04 -04:00
legop3 9702cf0f82 gruh i hate fun!! 2026-08-07 15:54:28 -04:00
legop3 a55257dd51 Merge pull request #22 from legop3/transportswap
Transportswap merge
2026-08-05 14:55:30 -04:00
legop3 8d1761afe2 forgot to build roverd binary lol lol 2026-08-04 02:08:56 -04:00
legop3 c7c52eb39c switched rover <-> server streams to rtsptcp, and moved mediamtx to be a server owned and configured child process! 2026-08-04 01:47:03 -04:00
legop3 28fcbad902 better replays panels 2026-08-03 03:48:30 -04:00
legop3 208b89fd7f add identity to socket auth hopefully will fix a lot of probem 2026-08-03 01:09:21 -04:00
legop3 15a60a58e6 moving a LOT of stuff around in web ui 2026-08-02 23:06:30 -04:00
legop3 3ebd1c7f9c adding added ad slot support for adddss 2026-08-01 01:58:34 -04:00
legop3 7fdcb53041 update 2026-07-31 22:28:35 -04:00
legop3 a2dde4fd3d image updates 2026-07-31 22:18:57 -04:00
legop3 8c5d98bed6 analytics and embed meta update!!! 2026-07-31 22:12:04 -04:00
legop3 1aecab66e7 Merge pull request #19 from Saul5662/feat/fun-commands
feat(commands): add a fun command category with 18 public `rs` commands
2026-07-29 01:55:28 -04:00
legop3 ef18ef89e0 Merge pull request #20 from Saul5662/fix/gain-ambiguous-selector
fix(commands): resolve duplicate nicknames in `rs gain`, add a help subcommand
2026-07-29 00:10:01 -04:00
Saul5662andClaude Opus 5 46bbe5c531 fix(commands): resolve duplicate nicknames in rs gain, add a help subcommand
`rs gain grant Saul` failed with "Selector matched multiple records. Suggestions:
Saul, cu_a28...33ab5c, Saul, cu_5a5...b6add3." Nicknames are not unique — one
person re-verifying from a new browser produces a second verified record with the
same name — so an exact nickname match can legitimately return several records,
and the shared resolver refuses on ambiguity.

That refusal is correct for deter, kick, and verify, where acting on the wrong of
two plausible targets is a moderation mistake. It is wrong for gain: granting a
volume ceiling to the wrong account belonging to the same person is recoverable.
So the disambiguation is local to this command and resolvers.js is untouched.

Order of preference on an exact match with several hits:

1. The account that is currently online, since that is who the admin is reacting
   to. Sockets are deduped to user ids first, so extra tabs do not matter.
2. Otherwise the first stored record.

Either way the reply says which happened and how many accounts shared the name.
A selector with no exact match still goes through the shared fuzzy resolver, so
typo tolerance and the existing error text are unchanged.

Also adds `rs gain help`, which lists the subcommands and explains the selector
and the online-wins rule. The previous unknown-subcommand fallback listed the
subcommands inline; it now shares the same help text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 05:08:25 +01:00
Saul5662andClaude Opus 5 dc8267073b feat(commands): play a bonk sound on the bonked user's rover
`rs bonk` now publishes a `fun.bonked` event carrying the rover the target is
currently driving, and a new audioForwardService listener plays a sound file on
it. Wired as an event rather than a direct call so the command layer stays
unaware of ffmpeg, matching how the charging-complete cue is already done.

The sound file goes at `server/assets/bonk.wav` and is NOT committed here. Note
that `server/assets` is the correct home rather than `server/public`: the webui
builds to `../server/public` with `emptyOutDir: true`, so anything stored there
is deleted by the next build.

Details:

- The audio is rate limited per rover on a 20s window, separate from the 4s text
  cooldown. Playback interrupts whatever that rover is forwarding, including a
  live microphone, so a group of people cannot chain it against one driver.
- No sound plays if the target is not currently driving, is not a real user, or
  is the caller themselves. The text bonk and the tally still work in all cases.
- A missing sound file logs once and skips, so the command works on a server that
  never installs one. A playback failure is caught and logged rather than
  surfacing as a failed chat command.
- Discord bonks play the sound too; only commands needing the caller's own socket
  are unavailable from there.

Server suite: 138 passing, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 05:05:36 +01:00
Saul5662andClaude Opus 5 eb0db3508f fix(commands): remove a stray NUL byte from the pairSeed separator
`pair.join('\0')` was written where `pair.join(' ')` was meant. The NUL made git
classify funHelpers.js as a binary file, so it showed as `Bin 0 -> 6397 bytes`
instead of a reviewable diff. The seed stayed deterministic either way, so no
behavior changes and the tests were already passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:58:24 +01:00
legop3 6aee49bfdb built webui lol 2026-07-28 23:57:43 -04:00
Saul5662andClaude Opus 5 a9428d3d72 test(commands): cover fun commands, cooldowns, and the dispatcher permission gate
89 new node:test cases. The dispatcher suite is the important one: it pins the
behavior the registry-driven permission gate replaced a hardcoded action list
with, asserting that admin-only commands are still admin-only, that the
self-policing commands (goal/reason/verify/deter) still reach their own handlers
as a non-admin, that unknown actions are still not public, and that `rsvp` is
still not a command.

Also covered:

- cooldown boundaries, including that a refused call does not extend the window
- actor identity keying across transports, and that extra browser tabs do not
  make a target ambiguous
- honk/spin refusing without drive control and being unreachable from Discord
- spin honouring applyPrivateDriveSafety instead of bypassing it
- boo speaking only canned text, never anything the caller typed
- disco obeying the room-light lock and restoring the lights when it ends
- mention sanitizing on replies and on stored nicknames rendered by bonkboard
- the stats store degrading to empty on a corrupt or wrong-shaped file

Full server suite: 126 passing, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:57:17 +01:00
legop3 30e961d727 Merge pull request #18 from Saul5662/feat/user-volume-gains
feat(audio): per-user horn/TTS/forward gains with admin-capped ceilings and a VIP boost flag
2026-07-28 23:55:29 -04:00
Saul5662andClaude Opus 5 a2fbbc100e feat(commands): add a fun command category with 18 public rs commands
Adds a `fun` category to the operator command registry, reachable identically
from site chat and Discord:

- text: bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted
- counters: bonkboard, pet, snitch
- hardware: honk, boo, spin, disco, vibecheck

Supporting pieces:

- `permission: 'public'` in the registry. The dispatcher previously decided
  non-admin access with a hardcoded chain of `action !== '...'` comparisons, so
  every new public command needed a dispatcher edit. That chain is replaced by a
  registry lookup plus SELF_GATED_ACTIONS, which names the commands that enforce
  their own permissions internally (goal/reason are read-public write-admin;
  verify/deter reject non-lockdown-admins themselves). Existing behavior for
  every pre-existing command is unchanged.
- `cooldowns.js`, a per-actor per-command in-memory gate. Site chat's own rate
  limit is per-socket-per-message and does not bound a specific command, so
  without this one person could turn `rs honk` into a siren. Site chat rebuilds
  its router per message, so the gate is created at module scope there and
  injected.
- `funStatsService`, a small JSON store for the persistent tallies. Counters are
  keyed by an actor key spanning transports (`user:<id>` / `discord:<id>`), and a
  Discord id has no row in `users`, so `user_feature_state` could not hold them
  without violating its foreign key.

Safety notes:

- `issueCommand` is the raw rover transport and performs none of the ownership,
  deterrence, or private-safety checks the socket `command` handler applies, so
  honk and spin re-check `canDrive` themselves and spin re-applies
  `applyPrivateDriveSafety`. Both are therefore site-chat only: a Discord message
  has no socket and can never satisfy those checks.
- `boo` speaks a canned taunt rather than caller-supplied text, so it cannot
  become an unmoderated TTS channel aimed at whoever is nearest a rover.
- `disco` obeys the existing room-light lock and the homeAssistant feature gate.
- The whole fun category is suspended in lockdown mode.
- Mute and deterrence already stop command-shaped chat before the router runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:51:40 +01:00
Saul5662andClaude Opus 5 42a7cefeba build: drop server/public build artifacts from this branch
The committed bundle was produced by a local `vite build`, which runs without
the injected analytics snippet. That stripped the page-wide `window.roverAnalytics`
Umami adapter and both analytics.otter.land script tags out of
server/public/index.html, and repointed the bundle hash at a local build.

Revert server/public to its origin/main state so this branch is source-only.
server/public should be rebuilt on deploy, where the analytics snippet exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:36:51 +01:00
Saul5662andClaude Opus 5 7272c8b4fa fix(commands): say "User not found" instead of "Selector not found"
The identity resolver's fuzzy-miss error was labelled "Selector", which is
internal jargon — the operator running `rs gain grant <vip>` or `rs kick
<user>` typed a username, not a "selector". Relabel that one message to
"User" so the chat reply reads plainly.

The sibling "Selector matched multiple records." and "Selector required."
messages are intentionally left alone for now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 04:34:19 +01:00
Saul5662andClaude 0e1ad8c6a0 feat(webui): Volume settings section and admin boost-cap editor
Adds a server-backed Volume card to page settings with horn, TTS, and
microphone sliders. Each slider is a 0-100% share of the ceiling the
server resolved for that user, and the card shows the resulting
multiplier plus whether the user is on the normal global limit or a
raised VIP limit. A slider whose ceiling is zero renders disabled rather
than pretending to do something.

The admin section gains sliders for the VIP boost hard caps beside the
existing global gains. Both editors now render from one GAIN_FIELDS list
through a shared GainSlider instead of six copied slider blocks.

Includes the rebuilt bundle so the served UI matches the source.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:10:26 +01:00
Saul5662andClaude 09c1257578 feat(commands): add 'rs gain' to grant the VIP audio gain boost
Admins can now run 'rs gain list|grant <vip>|revoke <vip>' from web chat or
Discord. Grant matches only against the verified list and revoke only
against current holders, so a nickname shared with an unverified visitor
reports not-found rather than resolving to someone ineligible. The action
joins the moderation set so lockdown narrows it to lockdown admins.

Also extracts the ceiling math into audioLevelsService/gainMath.js and
covers both it and the command with node:test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:07:44 +01:00
Saul5662andClaude f3b349bb4a feat(audio): per-user horn/TTS/forward volume with admin-capped ceilings
Every user now gets a personal 0-1 volume for horn, TTS, and mic forward.
The value is stored as a fraction of the ceiling that applies to them, so
lowering the global admin gain quiets everyone immediately instead of
leaving stale absolute values behind.

Ceilings resolve in three layers: the global admin gain is the default
ceiling; the audioGainBoost flag raises it to an admin-editable hard cap
(default 0.5x horn, 0.8x TTS, 0.4x forward); Math.max keeps the flag from
ever lowering a ceiling if the global gain is set higher than a cap.

Preferences live in identity feature state rather than a cookie so they
follow the user and cannot be raised client-side. The rover exposes gain
as three ALSA masters, so the resolved gains pushed to a rover are those
of the socket currently holding audio control -- re-pushed on driver
join/leave and every turn rotation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:04:22 +01:00
Saul5662andClaude 81f52c29d8 feat(identity): add audioGainBoost user flag
Adds an audio_gain_boost_enabled status column (plus _at/_by audit
fields) to user_status, exposed as user.audioGainBoost and copied onto
sockets as socket.data.hasAudioGainBoost. The flag marks VIPs allowed to
raise their personal horn/TTS/mic gain ceiling past the global admin gain
settings.

Grant/revoke goes through verificationService so socket flags refresh and
an event is published. Resolution is restricted to verified users, so an
unverified visitor sharing a nickname can never be matched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 03:59:47 +01:00
legop3 d000b8f4f8 disable hidden video disconnect by default 2026-07-26 21:08:24 -04:00
legop3 0f9bed9c99 slopodometering 2026-07-25 16:37:58 -04:00
legop3 8642fbac3c slopreporting 2026-07-25 15:54:38 -04:00
legop3 3a26b4871a slopfixing queue stuffs 2026-07-21 23:38:57 -04:00
legop3 b26daed93f better banning, new deter mute, replay discord fixes. 2026-07-21 22:50:12 -04:00
legop3 fb9565faf9 hapticks 3 3 3 3 3 3 2026-07-21 18:38:01 -04:00
legop3 358aa0b1d6 replay timestamps in filenamess 2026-07-21 16:29:10 -04:00
legop3 0ecee03f64 top video when tabbed out... 2026-07-19 23:48:03 -04:00
legop3 90d4f778a5 SLOP ANALYTICS!! 2026-07-19 22:43:56 -04:00
legop3 b261a4bfa2 Merge pull request #17 from legop3/aspectratioing
forgot to leave branch lole
2026-07-19 20:56:36 -04:00
legop3 dd1fd1e167 forgot to build 2026-07-19 20:54:46 -04:00
legop3 eba4b1dc1d add realtime upload/download host stats 2026-07-19 20:52:50 -04:00
legop3 cacd125fcb chat history and other stuffs, light commands replay fixes 2026-07-18 13:10:08 -04:00
legop3 8a4162683f snapshot fixes 2026-07-18 03:22:01 -04:00
legop3 387a5f47d7 oooopes 2026-07-18 00:13:04 -04:00
legop3 5b6947d92c wording adjust :3 :3 :3 2026-07-18 00:12:47 -04:00
legop3 cd8f8816c9 interinstance ui improvements 2026-07-18 00:09:51 -04:00
legop3 b86eec88f8 trying to fix zoom stopping camera 2026-07-17 23:33:46 -04:00
legop3 512adfc1e0 wawa 2026-07-17 23:25:03 -04:00
legop3 fe33f27bd0 bwah 2026-07-17 23:20:44 -04:00
legop3 afe8ffc62e ptz control updates 2026-07-17 23:06:12 -04:00
legop3 4e6e9e3021 new themeses!! 2026-07-17 21:01:48 -04:00
legop3 f9461433af better um interinstance ui um stuff yeah lole 2026-07-17 17:38:04 -04:00
legop3 b7d421c489 slop 2026-07-17 15:13:25 -04:00
legop3 c6b2843150 slop 2026-07-17 15:04:37 -04:00
legop3 b451849c02 slop 2026-07-17 15:04:31 -04:00
legop3 955f6f213d slop 2026-07-17 14:38:47 -04:00
legop3 c9842d7ba5 adjust 2026-07-17 14:28:44 -04:00
legop3 d7fb15d891 slopcurrent 2026-07-17 14:20:06 -04:00
legop3 1cd0e05b4a Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-07-17 13:21:24 -04:00
legop3 7927768731 ad bb worker to ignore 2026-07-17 13:21:16 -04:00
legop3 d9cb0c76b6 Merge pull request #16 from legop3/wiifit
Wiifit
2026-07-17 02:18:33 -04:00
legop3 351cb24458 slop 2026-07-17 02:15:47 -04:00
legop3 679563862d slop 2026-07-17 02:12:03 -04:00
legop3 8655cde0f1 slop 2026-07-17 01:55:59 -04:00
legop3 12090f23be slop 2026-07-17 01:22:27 -04:00
legop3 557b4b81a2 slop 2026-07-17 01:04:21 -04:00
legop3 0add90714b slop 2026-07-17 00:51:24 -04:00
legop3 fe64ec7758 slop 2026-07-17 00:14:43 -04:00
legop3 10f71edaf1 slop 2026-07-17 00:06:55 -04:00
legop3 e97d4056fa slop 2026-07-16 23:47:32 -04:00
legop3 c228bb107f slop 2026-07-16 23:30:01 -04:00
legop3 06aeca660b slop 2026-07-16 23:12:09 -04:00
legop3 4bd228547a slop 2026-07-16 22:52:03 -04:00
legop3 35561495b4 slop 2026-07-16 22:35:07 -04:00
legop3 8a9205b5b7 slop 2026-07-16 22:26:38 -04:00
legop3 a6c569ada4 slop 2026-07-16 22:09:53 -04:00
legop3 0d352d326d slop 2026-07-16 22:01:18 -04:00
legop3 7bc08af160 slop 2026-07-16 21:36:44 -04:00
legop3 9177e53fbf ptz queue stuff fix 2026-07-16 19:58:52 -04:00
legop3 5be5ad3b17 add route for ptz page 2026-07-16 19:47:20 -04:00
legop3 99bc00e96b replay panel and PTZ ui improvements 2026-07-16 19:25:18 -04:00
legop3 002b174259 fix chat ack waiting for commands to finish 2026-07-16 17:47:58 -04:00
legop3 e28ccc5e66 better /display ptz operator popup 2026-07-16 17:42:55 -04:00
legop3 efae430d65 snapshot user threshold.. 2026-07-16 17:29:00 -04:00
legop3 0c9df78070 Merge pull request #15 from legop3/commandsidequest
Commandsidequest
2026-07-16 17:09:41 -04:00
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
339 changed files with 21645 additions and 3870 deletions
+6 -1
View File
@@ -21,15 +21,20 @@ server/data/admin-reason.json
server/data/buttonbox-state.json
server/data/barcode-tts-cache/
server/data/rover-odometers.json
server/data/mediamtx.yml
webui/package-lock.json
!server/data/
!server/data/barcode-registry.json
webui/src/config/analytics.jsx
webui/src/config/driverAnalytics.json
webui/src/config/analytics.html
server/data/analytics.html
plans/barcodegames.txt
.gitignore
server/data/identity.sqlite
server/data/barcode-games.json
server/data/identity.sqlite-shm
server/data/identity.sqlite-wal
server/src/services/balanceBoardService/native/balance_board_worker
server/data/fleet-reports.sqlite
server/data/fleet-reports.sqlite-shm
server/data/fleet-reports.sqlite-wal
+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:
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:
- Building rovers
- Installing roverd on a rover's raspberry pi
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+134
View File
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
New Drive corner-pod HUD sketch, second design pass
Purpose: Shows true edge-mounted pods, physically attached expansions, and circular controls.
Scope: Static design communication only; production geometry remains an implementation decision.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900" role="img" aria-labelledby="title description">
<title id="title">Edge-mounted New Drive HUD pods</title>
<desc id="description">Four pods flow directly into the corners of a four by three rover video. Each has one inward rounded corner, and expansions attach directly along video edges.</desc>
<defs>
<linearGradient id="video" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#26343d" />
<stop offset="0.55" stop-color="#111827" />
<stop offset="1" stop-color="#1f2937" />
</linearGradient>
<linearGradient id="battery" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#22c55e" />
<stop offset="0.72" stop-color="#84cc16" />
<stop offset="1" stop-color="#eab308" />
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#000000" flood-opacity="0.5" />
</filter>
<style>
.pod { fill: #080a0f; fill-opacity: 0.86; stroke: #d1d5db; stroke-opacity: 0.26; stroke-width: 2; }
.expansion { fill: #080a0f; fill-opacity: 0.82; stroke: #d1d5db; stroke-opacity: 0.2; stroke-width: 2; }
.label { fill: #f8fafc; font-family: Inter, system-ui, sans-serif; font-weight: 700; }
.small { fill: #cbd5e1; font-family: Inter, system-ui, sans-serif; font-size: 17px; }
.tiny { fill: #94a3b8; font-family: Inter, system-ui, sans-serif; font-size: 14px; }
.arrow-button { fill: #1f2937; stroke: #e5e7eb; stroke-opacity: 0.55; stroke-width: 1.5; }
.arrow { fill: none; stroke: #f8fafc; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.track { fill: none; stroke: #334155; stroke-linecap: round; }
</style>
</defs>
<!-- The entire canvas is the shared 4:3 video/HUD coordinate space. -->
<rect width="1200" height="900" fill="url(#video)" />
<path d="M0 610 C235 510 390 590 600 515 C820 438 1000 520 1200 430 L1200 900 L0 900 Z" fill="#0b1516" opacity="0.76" />
<path d="M0 655 C240 555 425 635 630 560 C840 483 1020 560 1200 475" fill="none" stroke="#334155" stroke-width="5" opacity="0.42" />
<text x="600" y="450" text-anchor="middle" class="small" opacity="0.28">Rover video</text>
<!-- Top-left pod flows into the top and left edges; only its inward bottom-right corner rounds. -->
<g filter="url(#shadow)">
<path class="pod" d="M0 0 H190 V124 Q190 190 124 190 H0 Z" />
<circle class="track" cx="91" cy="91" r="55" stroke-width="14" />
<circle cx="91" cy="91" r="55" fill="none" stroke="#38bdf8" stroke-width="14" stroke-linecap="round" stroke-dasharray="255 346" transform="rotate(-90 91 91)" />
<text x="91" y="84" text-anchor="middle" class="tiny">Turn</text>
<text x="91" y="116" text-anchor="middle" class="label" font-size="30">0:42</text>
<circle class="arrow-button" cx="22" cy="22" r="16" />
<path class="arrow" d="M29 29 L16 16 M16 16 L25 16 M16 16 L16 25" />
<!-- This expansion begins exactly where the pod ends and continues directly into the top edge. -->
<path class="expansion" d="M190 0 H486 V50 Q486 78 458 78 H190 Z" />
<rect x="210" y="20" width="200" height="38" rx="8" fill="#7c3aed" opacity="0.72" />
<text x="310" y="45" text-anchor="middle" class="label" font-size="18">Rover name</text>
<circle class="arrow-button" cx="458" cy="39" r="14" />
<path class="arrow" d="M458 46 L458 32 M458 32 L452 38 M458 32 L464 38" />
</g>
<!-- Top-right pod and both expansions form one continuous edge-mounted cluster. -->
<g filter="url(#shadow)">
<path class="pod" d="M1010 0 H1200 V190 H1076 Q1010 190 1010 124 Z" />
<circle cx="1105" cy="91" r="59" fill="none" stroke="#334155" stroke-width="13" />
<circle cx="1105" cy="91" r="59" fill="none" stroke="url(#battery)" stroke-width="13" stroke-linecap="round" stroke-dasharray="300 371" transform="rotate(-90 1105 91)" />
<circle cx="1105" cy="91" r="42" fill="none" stroke="#334155" stroke-width="7" />
<circle cx="1105" cy="91" r="42" fill="none" stroke="#f59e0b" stroke-width="7" stroke-linecap="round" stroke-dasharray="112 264" transform="rotate(-90 1105 91)" />
<text x="1105" y="101" text-anchor="middle" class="label" font-size="30">81%</text>
<circle class="arrow-button" cx="1178" cy="22" r="16" />
<path class="arrow" d="M1171 29 L1184 16 M1184 16 L1175 16 M1184 16 L1184 25" />
<!-- Left expansion is attached to the pod at x=1010 and touches the top video edge. -->
<path class="expansion" d="M690 0 H1010 V78 H718 Q690 78 690 50 Z" />
<text x="718" y="31" class="label" font-size="18">Dock assist</text>
<text x="718" y="57" class="small">Dock rover</text>
<rect x="912" y="24" width="38" height="30" rx="6" fill="#312e81" stroke="#a5b4fc" />
<text x="931" y="45" text-anchor="middle" class="label" font-size="14">G</text>
<circle class="arrow-button" cx="980" cy="39" r="14" />
<path class="arrow" d="M980 46 L980 32 M980 32 L974 38 M980 32 L986 38" />
<!-- Lower expansion shares the pod's bottom edge and flows directly into the right edge. -->
<path class="expansion" d="M930 190 H1200 V420 H996 Q930 420 930 354 Z" />
<text x="958" y="225" class="label" font-size="18">Advanced power</text>
<text x="958" y="255" class="small">Voltage</text>
<rect x="958" y="266" width="214" height="8" rx="2" fill="#334155" />
<rect x="958" y="266" width="160" height="8" rx="2" fill="#38bdf8" />
<text x="958" y="306" class="small">Current</text>
<rect x="958" y="317" width="214" height="8" rx="2" fill="#334155" />
<rect x="958" y="317" width="90" height="8" rx="2" fill="#f59e0b" />
<text x="958" y="359" class="tiny">Computer 54 C</text>
<text x="958" y="383" class="tiny">Wi-Fi -58 dBm</text>
<circle class="arrow-button" cx="1174" cy="216" r="14" />
<path class="arrow" d="M1167 216 L1181 216 M1181 216 L1175 210 M1181 216 L1175 222" />
</g>
<!-- Bottom-left pod flows into the left and bottom edges with a compact triangular control group. -->
<g filter="url(#shadow)">
<path class="pod" d="M0 680 H220 Q300 680 300 760 V900 H0 Z" />
<circle cx="68" cy="758" r="38" fill="#172554" stroke="#60a5fa" stroke-width="2" />
<text x="68" y="754" text-anchor="middle" class="label" font-size="24"></text>
<text x="68" y="779" text-anchor="middle" class="tiny">E</text>
<circle cx="102" cy="850" r="38" fill="#3b2f0b" stroke="#facc15" stroke-width="2" />
<text x="102" y="846" text-anchor="middle" class="label" font-size="24"></text>
<text x="102" y="871" text-anchor="middle" class="tiny">R</text>
<circle cx="208" cy="798" r="57" fill="#3f1d2e" stroke="#fb7185" stroke-width="3" />
<text x="208" y="794" text-anchor="middle" class="label" font-size="25">Horn</text>
<text x="208" y="823" text-anchor="middle" class="tiny">H</text>
<circle class="arrow-button" cx="252" cy="754" r="14" />
<path class="arrow" d="M246 754 L258 754 M258 754 L253 749 M258 754 L253 759" />
<circle class="arrow-button" cx="22" cy="878" r="16" />
<path class="arrow" d="M29 871 L16 884 M16 884 L25 884 M16 884 L16 875" />
</g>
<!-- Bottom-right pod contains a circular tilt slider rather than a horizontal or pill track. -->
<g filter="url(#shadow)">
<path class="pod" d="M840 900 V760 Q840 680 920 680 H1200 V900 Z" />
<text x="1168" y="714" text-anchor="end" class="label" font-size="18">Camera tilt</text>
<circle class="track" cx="1030" cy="800" r="76" stroke-width="13" />
<circle cx="1030" cy="800" r="76" fill="none" stroke="#38bdf8" stroke-width="13" stroke-linecap="round" stroke-dasharray="285 478" transform="rotate(140 1030 800)" />
<circle cx="976" cy="746" r="13" fill="#e0f2fe" stroke="#0284c7" stroke-width="4" />
<text x="1030" y="808" text-anchor="middle" class="label" font-size="25">-12.5°</text>
<text x="1030" y="832" text-anchor="middle" class="tiny">Click for zero</text>
<circle cx="948" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
<text x="948" y="844" text-anchor="middle" class="label" font-size="14">J</text>
<circle cx="1112" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
<text x="1112" y="844" text-anchor="middle" class="label" font-size="14">U</text>
<circle class="arrow-button" cx="1178" cy="878" r="16" />
<path class="arrow" d="M1171 871 L1184 884 M1184 884 L1175 884 M1184 884 L1184 875" />
</g>
<!-- Immediate sensor overlays remain separate and are shown only as faint context here. -->
<path d="M360 900 Q600 808 840 900" fill="none" stroke="#ef4444" stroke-width="13" stroke-linecap="round" opacity="0.25" />
<path d="M410 886 Q600 820 790 886" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="12 10" opacity="0.4" />
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

+75
View File
@@ -0,0 +1,75 @@
- start work on new better ui layout, using components that already exist when possible
- centered rover video, full screen height
- rover HUD contains small but expandable rover telemetry UI and vis
- make newgen folder for new HUD elements. reuse old elements where possible
- make all new hud elements small and clean
- every hud element:
- is a nice small translucent thing with text icons or both
- can be expanded to show more relavent information
- is consistent. maybe make a reusable thing for this
- some specific hud elements:
- top bar:
- battery percentage that goes red and flashes and such
- turns hud that shows people in queue
- big in the middle
- left and right sides
- wheel drop indicators that show up when wheel drop is happening
- overcurrent and battery warnings
- bottom section:
- sensor elements that show up only when the sensor is "happening"
- bumpers
- front IR proximity sensors
- two sidebars
- sidebars contain all the stuff that isnt the rover
- left
- idk
- right
- chat, users, rovers list, and replay sources
- everything involving the rover is a video HUD, everything external is in the sidebars
## section 2
There will be corner mounted (one pod in each corner of the video), rounded pods in the HUD, which will contain gauges and controls for the rover
These pods will be collapsible, with a corner mounted arrow. the arrow points towards the corner when the pod is out, and points out of the corner when the pod is hidden.
There can also be "pod expansions" that will be in the corner of the pod and the side of the video. These are also collapsible, but they collapse into the side of the video that they are touching, instead of collapsing into the corner, with the same style arrow button as the pods.
For example, a pod in the top left is open. This pod has an expansion to it's right that is also open. I can collapse the pod into the corner, the expansion stays, it gets moved into the top left corner where the pod was.
- corner pods:
- top left
- pod
- turns timer
- round gauge circle that ticks down with time
- inside it, is the turn countdown
- this pod goes away when theres nothing to count
- right of pod expansion
- rover name with colored background
- expanded by default
- top right
- pod
- round rover battery bar gauge, based off how battery bar looks
- concentric to this bar is an unlabeled current gauge, styled after the current bar that the top down map contains
- inside the circle, is the battery percentage.
- left of pod expansion
- dock assist button and keybind
- expanded by default
- below pod expansion
- combined advanced power view for the roomba with other info from rover host stats
- bottom left
- pod
- has circular buttons for laser, horn, and headlight
- each button is a related icon and the keybind label for the feature
- arranged nicely
- horn button is larger, and contains an arrow to open the horn settings menu
- this pod disappears when none of these things are enabled
- if one of the button's features is not enabled, that button should go away
- bottom right
- pod
- rounded camera tilt slider, with keybind label on each end for up / down
- in the area inside the slider, show the tilt degrees
- clicking the degrees label should set camera tilt to 0
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
# Simple spectator bot
A spectator bot connects to the rover server with Socket.IO. It can receive the current session, read chat, and send messages that are visually tagged as bot messages.
## Install
Create a small Node.js project and install the Socket.IO client:
```bash
npm install socket.io-client
```
## Example bot
Create `bot.js`:
```js
import { io } from 'socket.io-client';
// Replace this with the public URL of the MultiRoombaRover server.
const socket = io('https://your-rover-server.example', {
// Match the transports supported by the server while retaining polling as a
// fallback for networks or proxies that do not allow WebSocket connections.
transports: ['websocket', 'polling'],
});
// Socket.IO acknowledgements use callbacks. This small wrapper turns them into
// promises so setup failures and rejected chat messages are easy to handle.
function emitWithAck(event, payload) {
return new Promise((resolve, reject) => {
socket.emit(event, payload, (response = {}) => {
if (response.error) {
reject(new Error(response.error));
return;
}
resolve(response);
});
});
}
socket.on('connect', async () => {
console.log('Connected:', socket.id);
try {
// Set the name that will appear beside this connection and its messages.
await emitWithAck('nickname:set', {
nickname: 'My spectator bot',
});
// Ask the server to make this passive connection a spectator. Performing
// this after every connection also restores the role after a reconnect.
await emitWithAck('session:setRole', {
role: 'spectator',
});
console.log('Connected as a spectator');
} catch (error) {
console.error('Spectator setup failed:', error.message);
}
});
// Each session:sync event is a complete current session snapshot. Replace any
// previously stored session with this object instead of merging snapshots.
socket.on('session:sync', (session) => {
console.log('Session:', session);
});
// chat:init contains the recent chat history available when the bot connects.
socket.on('chat:init', (messages) => {
console.log('Recent chat:', messages);
});
// chat:message fires whenever a new message is broadcast, including messages
// sent by this bot itself.
socket.on('chat:message', (message) => {
console.log(`${message.nickname || 'Unknown'}: ${message.text}`);
});
socket.on('disconnect', (reason) => {
console.log('Disconnected:', reason);
});
// Setting bot to true adds the normal bot tag to the displayed chat message.
// It does not grant the connection any additional permissions.
function sendBotMessage(text) {
return emitWithAck('chat:send', {
text,
bot: true,
});
}
// Send one example message after the connection has had time to finish setup.
// A real bot would call sendBotMessage from its own message-handling logic.
setTimeout(() => {
sendBotMessage('Hello from my spectator bot!').catch((error) => {
console.error('Message failed:', error.message);
});
}, 5000);
```
Run it with:
```bash
node bot.js
```
## Events used
- `nickname:set` sets the bot's visible nickname.
- `session:setRole` changes the connection to a spectator.
- `session:sync` provides the latest complete session state.
- `chat:init` provides recent chat history after connecting.
- `chat:message` provides new chat messages.
- `chat:send` sends a chat message. Include `bot: true` to give it the bot tag.
The server can reject spectator access or a chat message. Always check the acknowledgement callback, as the example does, so those errors are not silently ignored.
+5 -3
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1
fi
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
# forward URL is data produced by roverd, and treating it as shell code would
# break on normal SRT query-string characters such as '&'.
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is
# data produced by roverd and must never be interpreted as executable shell code.
load_env_file() {
local content=""
@@ -95,6 +94,9 @@ run_pipeline() {
-flags low_delay
-analyzeduration 200k
-probesize 32k
# The forwarded-audio URL is RTSP. Pinning TCP avoids ffmpeg negotiating the
# separate unreliable RTP/UDP transport that the server intentionally disables.
-rtsp_transport tcp
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
-vn
)
+20 -8
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1
fi
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
# contain characters such as '&' and '#!', so sourcing this file would treat a
# data file as code and can split a valid URL into shell control operators.
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data;
# sourcing this file would unnecessarily treat server-provided values as shell code.
load_env_file() {
local content=""
@@ -88,6 +87,7 @@ else
fi
run_pipeline() {
local -a pipeline_statuses=()
local ffmpeg_args=(
-hide_banner
-loglevel warning
@@ -123,13 +123,14 @@ run_pipeline() {
-frame_duration 20
-compression_level 0
# Mirror the video publisher's MPEG-TS low-latency settings. Without
# these, ffmpeg is allowed to hold packets for mux timing, which is
# exactly the wrong tradeoff for live rover feedback.
# RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly
# MPEG-TS demux without changing microphone capture or encoding quality. TCP is
# required for the same reliable local-network behavior as the video publisher.
-flush_packets 1
-muxdelay 0
-muxpreload 0
-f mpegts
-f rtsp
-rtsp_transport tcp
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
)
@@ -146,6 +147,17 @@ run_pipeline() {
# latency compared with the old 65,536-byte buffer.
arecord -D "${CAPTURE_DEVICE}" -f S32_LE -c "${ROVERD_AUDIO_CAPTURE_CHANNELS}" -r "${ROVERD_AUDIO_CAPTURE_SAMPLE_RATE}" -B "${AUDIO_ALSA_BUFFER_BYTES}" -F "${AUDIO_ALSA_PERIOD_BYTES}" -q -t raw \
| "${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}"
pipeline_statuses=("${PIPESTATUS[@]}")
# PIPESTATUS belongs to the pipeline that just finished and is replaced by the next shell
# command. Capture it immediately, then return the publisher failure first because that is
# normally the reason arecord receives a secondary broken pipe.
LAST_ARECORD_STATUS="${pipeline_statuses[0]:-unknown}"
LAST_FFMPEG_STATUS="${pipeline_statuses[1]:-unknown}"
if [[ "${LAST_FFMPEG_STATUS}" != "0" ]]; then
return "${LAST_FFMPEG_STATUS}"
fi
return "${LAST_ARECORD_STATUS}"
}
trap 'kill 0 2>/dev/null' EXIT INT TERM
@@ -154,6 +166,6 @@ while true; do
if run_pipeline; then
exit 0
fi
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
echo "Audio-only publisher exited arecord=${LAST_ARECORD_STATUS:-unknown} ffmpeg=${LAST_FFMPEG_STATUS:-unknown}, restarting in 2s..." >&2
sleep 2
done
+6 -4
View File
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1
fi
# Load roverd's generated media.env as data instead of sourcing it as shell.
# The SRT publish URL contains normal query-string characters like '&' and '#!',
# so evaluating the file would be both fragile and unnecessary.
# Load roverd's generated media.env as data instead of sourcing it as shell. URLs are
# configuration data, so evaluating the file would be both fragile and unnecessary.
load_env_file() {
local content=""
@@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
fi
run_pipeline() {
# Keep laptop rovers on the same transport contract as Pi camera rovers. This changes
# only the encoded stream's carrier; V4L2 capture and H264 encoding remain untouched.
"${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
@@ -130,7 +131,8 @@ run_pipeline() {
-flush_packets 1 \
-muxdelay 0 \
-muxpreload 0 \
-f mpegts \
-f rtsp \
-rtsp_transport tcp \
"${ROVERD_VIDEO_PUBLISH_URL}"
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# These publishers contain hardware-facing infinite retry loops, so executing them in a unit
# test would require unsafe process-group traps and fake camera/ALSA devices. Pin the small
# transport boundary directly instead: every publisher must request RTSP/TCP and none may
# reintroduce the high-latency MPEG-TS muxer.
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
assert_rtsp_tcp() {
local file="$1"
if ! grep -q -- '-f rtsp' "$file"; then
echo "Missing RTSP muxer in $file" >&2
exit 1
fi
if ! grep -q -- '-rtsp_transport tcp' "$file"; then
echo "Missing RTSP/TCP pin in $file" >&2
exit 1
fi
if grep -q -- '-f mpegts' "$file"; then
echo "Unexpected MPEG-TS muxer in $file" >&2
exit 1
fi
}
assert_rtsp_tcp "$SCRIPT_DIR/video-publisher.sh"
assert_rtsp_tcp "$SCRIPT_DIR/debian-laptop-video-publisher.sh"
assert_rtsp_tcp "$SCRIPT_DIR/audio-only-publisher.sh"
# The speaker path reads rather than publishes, so it has no output muxer. It must still pin
# RTSP/TCP before its input URL to match the server's TCP-only listener.
if ! grep -q -- '-rtsp_transport tcp' "$SCRIPT_DIR/audio-forward-listener.sh"; then
echo "Missing RTSP/TCP input pin in audio-forward-listener.sh" >&2
exit 1
fi
echo "Media publisher transport checks passed"
+10
View File
@@ -29,6 +29,15 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1
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
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
exit 1
@@ -86,3 +95,4 @@ log "Repository fast-forward pull complete"
# drift away from the normal manual install path.
"$ROVERD_REPO_DIR/pi/install_roverd.sh"
log "Installer completed successfully"
systemctl reboot
+7 -1
View File
@@ -110,6 +110,10 @@ else
fi
run_pipeline() {
# MPEG-TS added most of the former rover-to-browser latency inside MediaMTX's
# demuxer. RTSP carries the same encoded H264 without changing the camera or codec.
# TCP is explicit because plain RTSP/RTP over UDP has no retransmission and proved
# unreliable even though MediaMTX still reported the incomplete stream as ready.
"${LIBCAMERA_BIN_PATH}" \
--inline \
--timeout 0 \
@@ -120,6 +124,7 @@ run_pipeline() {
--framerate "${ROVERD_VIDEO_FPS}" \
--bitrate "${ROVERD_VIDEO_BITRATE}" \
--codec h264 \
--intra 120 \
--profile baseline \
--denoise auto \
--nopreview \
@@ -141,7 +146,8 @@ run_pipeline() {
-flush_packets 1 \
-muxdelay 0 \
-muxpreload 0 \
-f mpegts \
-f rtsp \
-rtsp_transport tcp \
"${ROVERD_VIDEO_PUBLISH_URL}"
}
+6 -6
View File
@@ -13,7 +13,7 @@ write_media_env_placeholder() {
# Managed by roverd; placeholder values will be overwritten at runtime.
ROVERD_VIDEO_ENABLE=1
ROVERD_VIDEO_PUBLISHER=pi-libcamera
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
ROVERD_VIDEO_DEVICE=
ROVERD_VIDEO_INPUT_FORMAT=
ROVERD_VIDEO_WIDTH=640
@@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000
ROVERD_VIDEO_INVERT=1
ROVERD_VIDEO_SENSOR_MODE=1296:972
ROVERD_AUDIO_CAPTURE_ENABLE=0
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
ROVERD_AUDIO_CAPTURE_DEVICE=hw:0,0
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
ROVERD_AUDIO_CAPTURE_CHANNELS=2
ROVERD_AUDIO_CAPTURE_BITRATE=510000
ROVERD_AUDIO_PLAYBACK_ENABLE=1
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
@@ -43,7 +43,7 @@ ENV
# Managed by roverd; placeholder values will be overwritten at runtime.
ROVERD_VIDEO_ENABLE=1
ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2
ROVERD_VIDEO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
ROVERD_VIDEO_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME
ROVERD_VIDEO_DEVICE=/dev/video0
ROVERD_VIDEO_INPUT_FORMAT=mjpeg
ROVERD_VIDEO_WIDTH=640
@@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000
ROVERD_VIDEO_INVERT=0
ROVERD_VIDEO_SENSOR_MODE=
ROVERD_AUDIO_CAPTURE_ENABLE=1
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
ROVERD_AUDIO_CAPTURE_PUBLISH_URL=rtsp://control-server.local:8554/CHANGE_ME-audio
ROVERD_AUDIO_CAPTURE_DEVICE=default
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
ROVERD_AUDIO_CAPTURE_CHANNELS=2
ROVERD_AUDIO_CAPTURE_BITRATE=510000
ROVERD_AUDIO_PLAYBACK_ENABLE=1
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
ROVERD_AUDIO_PLAYBACK_FORWARD_URL=rtsp://control-server.local:8554/CHANGE_ME-fwd
ROVERD_AUDIO_PLAYBACK_DEVICE=forward
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
+8 -1
View File
@@ -29,6 +29,7 @@ func main() {
defer cancel()
logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC)
console := roverd.NewConsoleNotifier(logger)
serialPort, err := roverd.OpenSerial(cfg.Serial)
if err != nil {
@@ -90,7 +91,13 @@ func main() {
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
go autoCharge.Run(ctx, sensorSamples)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, headlight, laser, logger)
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, headlight, laser, logger, console)
// Startup is announced only after every configured hardware dependency has
// initialized successfully. A message here therefore means the control loop
// is genuinely ready, rather than merely that systemd launched the process.
console.Notify("roverd started and hardware initialization completed.")
defer console.Notify("roverd stopped.")
retryDelay := time.Second
for ctx.Err() == nil {
+57 -50
View File
@@ -3,9 +3,11 @@ package roverd
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -77,10 +79,10 @@ type HornConfig struct {
}
type MediaConfig struct {
// PublishPort is shared by the derived video, microphone, and forwarded-audio
// SRT URLs. Keeping it at this level prevents each nested block from needing
// RTSPPort is shared by the derived video, microphone, and forwarded-audio
// RTSP URLs. Keeping it at this level prevents each nested block from needing
// to repeat the same server port when the common MediaMTX listener is used.
PublishPort int `yaml:"publishPort" json:"-"`
RTSPPort int `yaml:"rtspPort" json:"-"`
Manage bool `yaml:"manage" json:"manage"`
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
HealthInterval Duration `yaml:"healthInterval" json:"-"`
@@ -93,10 +95,12 @@ type VideoMediaConfig struct {
// Publisher selects the installed publisher script/pipeline family. The
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
// without changing the server-facing media shape again.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
// PublishURL is derived during validation. It remains in rover metadata for server-side
// consumers, but is not a second hand-written endpoint in /etc/roverd.yaml.
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"`
InputFormat string `yaml:"inputFormat" json:"-"`
Width int `yaml:"width" json:"-"`
@@ -111,9 +115,10 @@ type AudioCaptureConfig struct {
// AudioCapture describes the rover microphone stream that browsers can
// subscribe to as "<rover>-audio". A disabled capture block still has
// normalized defaults so enabling it only requires flipping enabled: true.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
// PublishURL follows the same derived-only contract as the video path.
PublishURL string `yaml:"-" json:"publishUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"`
SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"`
@@ -125,9 +130,10 @@ type AudioPlaybackConfig struct {
// MediaMTX for playback on the rover speaker. The URL is a request/read URL
// for the rover listener, while the server converts it to publish mode when
// it needs to inject audio.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
// ForwardURL is derived because the server and rover must agree on the exact -fwd path.
ForwardURL string `yaml:"-" json:"forwardUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"`
Normalize bool `yaml:"normalize" json:"-"`
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
@@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) {
},
},
Media: MediaConfig{
PublishPort: 9000,
RTSPPort: 8554,
HealthInterval: Duration{Duration: 30 * time.Second},
Video: VideoMediaConfig{
Enabled: true,
@@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) {
if cfg.BRC.GPIOChip == "" {
cfg.BRC.GPIOChip = "gpiochip0"
}
if cfg.Media.PublishPort <= 0 {
cfg.Media.PublishPort = 9000
if cfg.Media.RTSPPort <= 0 {
cfg.Media.RTSPPort = 8554
}
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
return nil, fmt.Errorf("media: %w", err)
@@ -432,25 +438,25 @@ func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) e
file is written. This keeps the Pi behavior stable while making laptop
and future publisher variants explicit configuration choices.
*/
if cfg.PublishPort <= 0 {
cfg.PublishPort = 9000
if cfg.RTSPPort <= 0 {
cfg.RTSPPort = 8554
}
if cfg.HealthInterval.Duration <= 0 {
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
}
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil {
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.RTSPPort); err != nil {
return fmt.Errorf("video: %w", err)
}
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil {
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.RTSPPort); err != nil {
return fmt.Errorf("audioCapture: %w", err)
}
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil {
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.RTSPPort); err != nil {
return fmt.Errorf("audioPlayback: %w", err)
}
return nil
}
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error {
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, rtspPort int) error {
if cfg.Service == "" {
cfg.Service = "video-publisher.service"
}
@@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
cfg.SensorMode = "1296:972"
}
if cfg.PublishURL == "" {
derived, err := derivePublishURL(serverURL, roverName, publishPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
/*
Always derive this endpoint. Older rover configs can contain an explicit SRT publishUrl;
honoring it after a binary update would silently leave that rover on the old transport.
*/
derived, err := derivePublishURL(serverURL, roverName, rtspPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
return nil
}
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error {
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, rtspPort int) error {
if cfg.Service == "" {
cfg.Service = "audio-only-publisher.service"
}
@@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover
if cfg.Bitrate <= 0 {
cfg.Bitrate = 510000
}
if cfg.PublishURL == "" {
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
return nil
}
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error {
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, rtspPort int) error {
if cfg.Service == "" {
cfg.Service = "audio-forward-listener.service"
}
@@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov
if cfg.NormalizeFilter == "" {
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
}
if cfg.ForwardURL == "" {
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
if err != nil {
return fmt.Errorf("derive forwardUrl: %w", err)
}
cfg.ForwardURL = derived
derived, err := deriveReadURL(serverURL, roverName+"-fwd", rtspPort)
if err != nil {
return fmt.Errorf("derive forwardUrl: %w", err)
}
cfg.ForwardURL = derived
return nil
}
@@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
}
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "publish")
return deriveRTSPURL(serverURL, streamName, port)
}
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
return deriveSRTURL(serverURL, streamName, port, "request")
return deriveRTSPURL(serverURL, streamName, port)
}
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
func deriveRTSPURL(serverURL, streamName string, port int) (string, error) {
if streamName == "" {
return "", errors.New("missing stream name for publishUrl")
}
if mode == "" {
mode = "publish"
}
parsed, err := url.Parse(serverURL)
if err != nil {
return "", err
@@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
return "", errors.New("serverUrl missing host")
}
if port <= 0 {
port = 9000
port = 8554
}
/*
JoinHostPort handles both ordinary hostnames and bracketed IPv6 addresses. The rover name
is a MediaMTX path, so it is escaped independently instead of interpolated into the host.
RTSP distinguishes publishing from reading through protocol methods, which is why both
directions intentionally use the same URL shape.
*/
escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
return fmt.Sprintf("rtsp://%s/%s", net.JoinHostPort(host, strconv.Itoa(port)), escaped), nil
}
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
+67
View File
@@ -0,0 +1,67 @@
package roverd
import (
"fmt"
"log"
"os"
"sync"
"time"
)
const roverConsolePath = "/dev/tty1"
// ConsoleNotifier writes the small set of rover lifecycle events that must be
// visible even when nobody is logged in. This intentionally targets tty1
// directly instead of using wall: wall discovers recipients through utmp, so
// it does not reliably reach a virtual console that is only showing a login
// prompt.
type ConsoleNotifier struct {
path string
logger *log.Logger
mu sync.Mutex
}
// NewConsoleNotifier returns the production notifier for the rover's primary
// local virtual console. Keeping the path inside the notifier also gives tests
// a way to substitute a regular temporary file without touching a real TTY.
func NewConsoleNotifier(logger *log.Logger) *ConsoleNotifier {
return newConsoleNotifier(roverConsolePath, logger)
}
func newConsoleNotifier(path string, logger *log.Logger) *ConsoleNotifier {
return &ConsoleNotifier{path: path, logger: logger}
}
// Notify appends one self-contained alert to the console. Console output is a
// diagnostic convenience rather than part of rover control, so an unavailable
// tty is logged but never allowed to stop startup, reconnection, docking, or
// reboot behavior.
func (n *ConsoleNotifier) Notify(message string) {
if n == nil {
return
}
n.mu.Lock()
defer n.mu.Unlock()
console, err := os.OpenFile(n.path, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
n.logFailure("open", err)
return
}
defer console.Close()
// Leading and trailing CRLFs keep the alert separate from an agetty login
// prompt, while plain text avoids leaving an unknown terminal in a modified
// color or cursor state.
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05 UTC")
if _, err := fmt.Fprintf(console, "\r\n*** rover alert - %s ***\r\n%s\r\n", timestamp, message); err != nil {
n.logFailure("write", err)
}
}
func (n *ConsoleNotifier) logFailure(operation string, err error) {
if n.logger != nil {
n.logger.Printf("console notification %s failed for %s: %v", operation, n.path, err)
}
}
+40
View File
@@ -0,0 +1,40 @@
package roverd
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
func TestConsoleNotifierWritesVisibleAlert(t *testing.T) {
path := filepath.Join(t.TempDir(), "tty1")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("create fake console: %v", err)
}
notifier := newConsoleNotifier(path, log.New(io.Discard, "", 0))
notifier.Notify("control server connection lost")
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fake console: %v", err)
}
output := string(contents)
if !strings.Contains(output, "*** rover alert - ") {
t.Fatalf("alert header missing from %q", output)
}
if !strings.Contains(output, "control server connection lost") {
t.Fatalf("alert message missing from %q", output)
}
}
func TestConsoleNotifierTreatsMissingConsoleAsNonfatal(t *testing.T) {
// A missing TTY is normal on some headless or containerized hosts. The
// contract is therefore simply that Notify returns instead of escalating a
// display failure into a rover-process failure.
notifier := newConsoleNotifier(filepath.Join(t.TempDir(), "missing"), log.New(io.Discard, "", 0))
notifier.Notify("roverd started")
}
+93 -6
View File
@@ -3,6 +3,7 @@ package roverd
import (
"bufio"
"context"
"errors"
"fmt"
"math"
"os"
@@ -14,7 +15,7 @@ import (
)
const (
hostStatsInterval = 5 * time.Second
hostStatsInterval = 1 * time.Second
rootFilesystem = "/"
)
@@ -63,7 +64,24 @@ type WiFiStats struct {
TXBytes *uint64 `json:"txBytes,omitempty"`
RXPackets *uint64 `json:"rxPackets,omitempty"`
TXPackets *uint64 `json:"txPackets,omitempty"`
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
UploadMbps *float64 `json:"uploadMbps,omitempty"`
InactiveMs *int `json:"inactiveMs,omitempty"`
// networkSampledAt records the instant associated with the kernel byte
// counters. Keeping it out of JSON lets the websocket loop calculate rates
// with monotonic Go timestamps without expanding the browser contract with
// an implementation-only value.
networkSampledAt time.Time
}
// networkRateSample is scoped to one rover websocket connection. A new
// connection intentionally starts a new baseline so counters from an old boot
// or network interface lifetime can never create an artificial traffic spike.
type networkRateSample struct {
rxBytes uint64
txBytes uint64
sampledAt time.Time
}
// CollectHostStats gathers every source independently so one missing kernel
@@ -370,12 +388,81 @@ func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
return nil, err
}
// The interface is used only to ask iw about the active connection. It is
// not copied into WiFiStats because the UI does not need to expose it.
if err := enrichWiFiWithIW(ctx, iface, stats); err != nil {
return stats, err
// The interface is used only for local collection. It is not copied into
// WiFiStats because the UI does not need to expose Linux device names.
iwErr := enrichWiFiWithIW(ctx, iface, stats)
// Read the kernel counters after iw because iw also provides cumulative
// station counters. The kernel interface values deliberately win: they are
// the host-traffic source used for both the cumulative display and Mbps math.
// Link capacity still comes independently from iw's bitrate fields.
counterErr := enrichWiFiWithNetworkCounters(iface, stats)
return stats, errors.Join(counterErr, iwErr)
}
func enrichWiFiWithNetworkCounters(iface string, stats *WiFiStats) error {
basePath := "/sys/class/net/" + iface + "/statistics/"
rxBytes, err := readUintFile(basePath + "rx_bytes")
if err != nil {
return fmt.Errorf("read %s receive bytes: %w", iface, err)
}
return stats, nil
txBytes, err := readUintFile(basePath + "tx_bytes")
if err != nil {
return fmt.Errorf("read %s transmit bytes: %w", iface, err)
}
stats.RXBytes = &rxBytes
stats.TXBytes = &txBytes
// Capture the timestamp immediately beside the counter reads so unrelated
// host-stat collection latency cannot distort the elapsed-time divisor.
stats.networkSampledAt = time.Now()
return nil
}
func readUintFile(path string) (uint64, error) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
}
func applyNetworkThroughput(stats *WiFiStats, previous *networkRateSample) *networkRateSample {
if stats == nil || stats.RXBytes == nil || stats.TXBytes == nil || stats.networkSampledAt.IsZero() {
// Do not discard the last valid baseline during a temporary read failure.
// The next successful calculation then covers the full elapsed interval and
// remains an accurate average for all traffic transferred during the gap.
return previous
}
current := &networkRateSample{
rxBytes: *stats.RXBytes,
txBytes: *stats.TXBytes,
sampledAt: stats.networkSampledAt,
}
if previous == nil {
return current
}
elapsed := current.sampledAt.Sub(previous.sampledAt).Seconds()
// Linux counters can return to zero after an interface reset. Re-baselining
// on any decrease prevents unsigned underflow from becoming a huge false
// throughput spike in the host-stat card.
if elapsed <= 0 || current.rxBytes < previous.rxBytes || current.txBytes < previous.txBytes {
return current
}
downloadMbps := bytesToMbps(current.rxBytes-previous.rxBytes, elapsed)
uploadMbps := bytesToMbps(current.txBytes-previous.txBytes, elapsed)
stats.DownloadMbps = &downloadMbps
stats.UploadMbps = &uploadMbps
return current
}
func bytesToMbps(byteDelta uint64, elapsedSeconds float64) float64 {
// Mbps uses decimal megabits, matching network equipment and link-rate
// conventions: eight bits per byte and 1,000,000 bits per megabit.
return roundOneDecimal((float64(byteDelta) * 8) / elapsedSeconds / 1_000_000)
}
func readWirelessStats() (string, *WiFiStats, error) {
+79
View File
@@ -0,0 +1,79 @@
package roverd
import (
"testing"
"time"
)
func TestApplyNetworkThroughputCalculatesMbpsFromActualElapsedTime(t *testing.T) {
startedAt := time.Unix(100, 0)
previous := &networkRateSample{rxBytes: 1_000, txBytes: 2_000, sampledAt: startedAt}
rxBytes := uint64(2_001_000)
txBytes := uint64(1_002_000)
stats := &WiFiStats{
RXBytes: &rxBytes,
TXBytes: &txBytes,
networkSampledAt: startedAt.Add(2 * time.Second),
}
next := applyNetworkThroughput(stats, previous)
if stats.DownloadMbps == nil || *stats.DownloadMbps != 8.0 {
t.Fatalf("expected 8.0 Mbps download, got %v", stats.DownloadMbps)
}
if stats.UploadMbps == nil || *stats.UploadMbps != 4.0 {
t.Fatalf("expected 4.0 Mbps upload, got %v", stats.UploadMbps)
}
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
t.Fatalf("expected current counters to become the next baseline, got %#v", next)
}
}
func TestApplyNetworkThroughputFirstSampleOnlyEstablishesBaseline(t *testing.T) {
rxBytes := uint64(100)
txBytes := uint64(200)
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: time.Unix(100, 0)}
next := applyNetworkThroughput(stats, nil)
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
t.Fatalf("expected no rates for the first sample, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
}
if next == nil {
t.Fatal("expected the first valid sample to establish a baseline")
}
}
func TestApplyNetworkThroughputCounterResetEstablishesNewBaseline(t *testing.T) {
startedAt := time.Unix(100, 0)
previous := &networkRateSample{rxBytes: 10_000, txBytes: 20_000, sampledAt: startedAt}
rxBytes := uint64(10)
txBytes := uint64(20)
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: startedAt.Add(time.Second)}
next := applyNetworkThroughput(stats, previous)
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
t.Fatalf("expected no rates after a counter reset, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
}
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
t.Fatalf("expected reset counters to become the new baseline, got %#v", next)
}
}
func TestApplyNetworkThroughputInvalidElapsedTimeEstablishesNewBaseline(t *testing.T) {
sampledAt := time.Unix(100, 0)
previous := &networkRateSample{rxBytes: 100, txBytes: 200, sampledAt: sampledAt}
rxBytes := uint64(200)
txBytes := uint64(300)
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: sampledAt}
next := applyNetworkThroughput(stats, previous)
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
t.Fatalf("expected no rates with zero elapsed time, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
}
if next == nil || next.sampledAt != sampledAt {
t.Fatalf("expected invalid timing sample to become the new baseline, got %#v", next)
}
}
+80
View File
@@ -0,0 +1,80 @@
package roverd
// These tests pin the network-agnostic RTSP contract. A rover provides its server URL and name
// once; all three media paths must then resolve to distinct, safely escaped MediaMTX paths.
import (
"strings"
"testing"
)
func TestMediaURLsDeriveFromServerURLAndRoverName(t *testing.T) {
cfg := MediaConfig{
Video: VideoMediaConfig{Enabled: true},
AudioCapture: AudioCaptureConfig{Enabled: true},
AudioPlayback: AudioPlaybackConfig{Enabled: true},
}
if err := validateMediaConfig(&cfg, "ws://control-server.local:8080/rover", "rover one"); err != nil {
t.Fatalf("validate media config: %v", err)
}
wants := map[string]string{
"video": "rtsp://control-server.local:8554/rover%20one",
"mic": "rtsp://control-server.local:8554/rover%20one-audio",
"speaker": "rtsp://control-server.local:8554/rover%20one-fwd",
}
got := map[string]string{
"video": cfg.Video.PublishURL,
"mic": cfg.AudioCapture.PublishURL,
"speaker": cfg.AudioPlayback.ForwardURL,
}
for name, want := range wants {
if got[name] != want {
t.Errorf("%s URL: got %q, want %q", name, got[name], want)
}
}
if cfg.RTSPPort != 8554 {
t.Fatalf("RTSP port: got %d, want 8554", cfg.RTSPPort)
}
}
func TestExplicitMediaPortAppliesToEveryRTSPPath(t *testing.T) {
cfg := MediaConfig{
RTSPPort: 10554,
Video: VideoMediaConfig{Enabled: true},
AudioCapture: AudioCaptureConfig{Enabled: true},
AudioPlayback: AudioPlaybackConfig{Enabled: true},
}
if err := validateMediaConfig(&cfg, "ws://media.example/rover", "r1"); err != nil {
t.Fatalf("validate media config: %v", err)
}
for name, value := range map[string]string{
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
} {
if !strings.Contains(value, ":10554/") {
t.Errorf("%s URL did not use configured port: %q", name, value)
}
}
}
func TestLegacyExplicitSRTURLsCannotKeepAnUpdatedRoverOnTheOldTransport(t *testing.T) {
/*
Deployed rover configs can still contain these former fields. Validation must replace
them unconditionally so updating roverd is sufficient to move the whole media path.
*/
cfg := MediaConfig{
Video: VideoMediaConfig{Enabled: true, PublishURL: "srt://old/video"},
AudioCapture: AudioCaptureConfig{Enabled: true, PublishURL: "srt://old/audio"},
AudioPlayback: AudioPlaybackConfig{Enabled: true, ForwardURL: "srt://old/forward"},
}
if err := validateMediaConfig(&cfg, "ws://new-server.local:8080/rover", "r1"); err != nil {
t.Fatalf("validate media config: %v", err)
}
for name, value := range map[string]string{
"video": cfg.Video.PublishURL, "mic": cfg.AudioCapture.PublishURL, "speaker": cfg.AudioPlayback.ForwardURL,
} {
if !strings.HasPrefix(value, "rtsp://new-server.local:8554/") {
t.Errorf("%s retained an old transport URL: %q", name, value)
}
}
}
+2 -1
View File
@@ -22,7 +22,8 @@ battery:
maxWheelSpeed: 350
media:
publishPort: 9000
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
rtspPort: 8554
manage: true
healthUrl: ""
healthInterval: 30s
+2 -4
View File
@@ -17,7 +17,8 @@ battery:
urgent: 1650
maxWheelSpeed: 350
media:
publishPort: 9000
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
rtspPort: 8554
manage: true
healthUrl: ""
healthInterval: 30s
@@ -25,7 +26,6 @@ media:
enabled: true
service: video-publisher.service
publisher: pi-libcamera
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
width: 640
height: 480
fps: 30
@@ -36,7 +36,6 @@ media:
audioCapture:
enabled: false
service: audio-only-publisher.service
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
device: hw:0,0
sampleRate: 48000
channels: 2
@@ -44,7 +43,6 @@ media:
audioPlayback:
enabled: true
service: audio-forward-listener.service
forwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
device: forward
normalize: true
cameraServo:
+86 -5
View File
@@ -24,8 +24,12 @@ type WSClient struct {
headlight *GPIOToggle
laser *GPIOToggle
log *log.Logger
console *ConsoleNotifier
recoverMu sync.Mutex
recovering bool
watchdogMu sync.Mutex
watchdogOpen bool
watchdogOK bool
ttsQueue chan *ttsPayload
chromeTTS *chromeTTSDaemon
lastAux motorPWMPayload
@@ -41,7 +45,7 @@ type WSClient struct {
audioMu sync.RWMutex
}
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, headlight *GPIOToggle, laser *GPIOToggle, logger *log.Logger) *WSClient {
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, headlight *GPIOToggle, laser *GPIOToggle, logger *log.Logger, console *ConsoleNotifier) *WSClient {
var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
@@ -65,6 +69,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
headlight: headlight,
laser: laser,
log: logger,
console: console,
ttsQueue: ttsQueue,
chromeTTS: chromeTTS,
audioLevels: AudioLevels{
@@ -305,6 +310,7 @@ func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
go func() {
time.Sleep(delay)
c.console.Notify("Remote reboot requested. Rebooting the rover now.")
c.log.Printf("rebooting pi after remote reboot command")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
@@ -331,6 +337,7 @@ func (c *WSClient) handleUpdateCommand() error {
c.emitEvent("system.updateStarting", map[string]any{
"source": "remoteCommand",
})
c.console.Notify("Remote software update requested. roverd will restart if the update succeeds.")
// The helper is launched asynchronously because a successful update may
// restart roverd before this websocket command could stream progress back to
@@ -495,6 +502,10 @@ func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
lastRecovery = now
resetTimer()
case frame := <-c.sensorFrames:
// A real sensor frame is the authoritative end of a watchdog
// episode. Successfully sending the OI restart commands alone does
// not prove that the Roomba resumed producing sensor data.
c.closeSensorWatchdogEpisode()
lastFrame = time.Now()
resetTimer()
msg := sensorMessage{
@@ -531,14 +542,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
}
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
var previousNetworkSample *networkRateSample
send := func() bool {
// Host stats are collected on demand so each outbound message describes
// the current Pi state. Collection failures are encoded into the stats
// payload, which keeps this telemetry path from closing the rover socket.
stats := CollectHostStats(ctx)
// Throughput is derived here because this loop owns the ordered, periodic
// samples for one connection. CollectHostStats stays independent, while a
// reconnect automatically receives a clean counter baseline.
previousNetworkSample = applyNetworkThroughput(stats.WiFi, previousNetworkSample)
msg := hostStatsMessage{
Type: "hostStats",
Timestamp: time.Now().UnixMilli(),
Stats: CollectHostStats(ctx),
Stats: stats,
}
if err := writeJSON(ctx, conn, msg); err != nil {
c.log.Printf("host stats send failed: %v", err)
@@ -634,6 +652,7 @@ func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
func (c *WSClient) markConnected() {
c.connMu.Lock()
wasConnected := c.connected
c.connected = true
c.seekIssued = false
c.rebootIssued = false
@@ -647,13 +666,19 @@ func (c *WSClient) markConnected() {
c.rebootT = nil
}
c.connMu.Unlock()
// Only print on a state transition. Run is retried indefinitely, and a
// message on every successful internal operation would quickly bury the
// useful lifecycle history at the login prompt.
if !wasConnected {
c.console.Notify("Control server connected.")
}
}
func (c *WSClient) markDisconnected() {
c.connMu.Lock()
if c.connected {
c.connected = false
}
wasConnected := c.connected
c.connected = false
if c.disconnectT == nil {
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
}
@@ -661,6 +686,13 @@ func (c *WSClient) markDisconnected() {
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
}
c.connMu.Unlock()
// Initial dial failures are already represented by the startup message and
// journal retry logs. The prominent disconnect alert is reserved for losing
// a connection that was actually established.
if wasConnected {
c.console.Notify("Control server connection lost. Automatic dock seek in 1 minute; rover reboot in 6 minutes if the connection is not restored.")
}
}
func (c *WSClient) handleDisconnectTimeout() {
@@ -672,6 +704,7 @@ func (c *WSClient) handleDisconnectTimeout() {
c.seekIssued = true
c.connMu.Unlock()
c.console.Notify("Control server has been disconnected for 1 minute. Seeking the dock now.")
if err := c.adapter.SeekDock(); err != nil {
c.log.Printf("seek dock on disconnect failed: %v", err)
return
@@ -688,6 +721,7 @@ func (c *WSClient) handleRebootTimeout() {
c.rebootIssued = true
c.connMu.Unlock()
c.console.Notify("Control server has been disconnected for 6 minutes. Rebooting the rover now.")
c.log.Printf("rebooting pi after prolonged websocket disconnect")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
@@ -713,10 +747,16 @@ func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Dura
c.emitEvent("sensorWatchdog.restart", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
if c.openSensorWatchdogEpisode() {
c.console.Notify(fmt.Sprintf("Sensor watchdog is restarting the Roomba sensor stream after %.1f seconds without data.", idleFor.Seconds()))
}
if err := c.adapter.StartOI(); err != nil {
c.log.Printf("watchdog start OI failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
// Unlike the restart notice, every concrete command failure is useful
// diagnostic information and may change between recovery attempts.
c.console.Notify(fmt.Sprintf("Sensor watchdog recovery failed while starting the Roomba OI: %v", err))
return
}
if cmdPause > 0 {
@@ -726,12 +766,53 @@ func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Dura
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
c.log.Printf("watchdog start stream failed: %v", err)
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
c.console.Notify(fmt.Sprintf("Sensor watchdog recovery failed while starting the sensor stream: %v", err))
return
}
c.emitEvent("sensorWatchdog.ok", map[string]any{
"idleMs": idleFor.Milliseconds(),
})
if c.markSensorWatchdogCommandsOK() {
// Match the existing sensorWatchdog.ok contract precisely: this says
// the recovery commands succeeded, not that a new frame has arrived.
c.console.Notify("Sensor watchdog successfully sent the sensor-stream restart commands.")
}
}
// openSensorWatchdogEpisode reports whether this is the first recovery attempt
// since sensor frames stopped. The watchdog can retry every few seconds, so
// tracking the outage as one episode keeps the login console readable.
func (c *WSClient) openSensorWatchdogEpisode() bool {
c.watchdogMu.Lock()
defer c.watchdogMu.Unlock()
if c.watchdogOpen {
return false
}
c.watchdogOpen = true
c.watchdogOK = false
return true
}
// markSensorWatchdogCommandsOK suppresses duplicate success notices while the
// rover is still waiting for a real frame to close the current outage.
func (c *WSClient) markSensorWatchdogCommandsOK() bool {
c.watchdogMu.Lock()
defer c.watchdogMu.Unlock()
if c.watchdogOK {
return false
}
c.watchdogOK = true
return true
}
func (c *WSClient) closeSensorWatchdogEpisode() {
c.watchdogMu.Lock()
c.watchdogOpen = false
c.watchdogOK = false
c.watchdogMu.Unlock()
}
func isModeOpcode(op byte) bool {
+27
View File
@@ -0,0 +1,27 @@
package roverd
import "testing"
func TestSensorWatchdogConsoleEpisodeSuppressesDuplicateStatusMessages(t *testing.T) {
client := &WSClient{}
if !client.openSensorWatchdogEpisode() {
t.Fatal("first recovery attempt should announce the watchdog episode")
}
if client.openSensorWatchdogEpisode() {
t.Fatal("repeated recovery attempt should not repeat the outage announcement")
}
if !client.markSensorWatchdogCommandsOK() {
t.Fatal("first successful command restart should be announced")
}
if client.markSensorWatchdogCommandsOK() {
t.Fatal("repeated successful command restart should not be announced")
}
// Receiving a real frame closes the outage. A later silence is a distinct
// incident and must therefore be visible on the console again.
client.closeSensorWatchdogEpisode()
if !client.openSensorWatchdogEpisode() {
t.Fatal("new outage after a sensor frame should be announced")
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Audio Forward Listener (SRT -> ALSA)
Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA)
After=network-online.target roverd.service
Wants=network-online.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Audio Publisher (ALSA -> SRT)
Description=Rover Audio Publisher (ALSA -> RTSP/TCP)
After=network-online.target roverd.service
Wants=network-online.target
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT)
Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP)
After=network-online.target roverd.service
Wants=network-online.target
+5 -1
View File
@@ -1,11 +1,15 @@
[Unit]
Description=Multi-Roomba rover control agent
After=network-online.target mediamtx.service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
# roverd cannot report an unexpected exit after its process is already gone.
# ExecStopPost fills only that gap; ordinary lifecycle messages remain owned by
# roverd, and SERVICE_RESULT prevents clean stops from being labeled failures.
ExecStopPost=/bin/sh -c 'if [ "$SERVICE_RESULT" != "success" ]; then /usr/bin/printf "\r\n*** rover alert ***\r\nroverd exited unexpectedly; systemd will restart it.\r\n" > /dev/tty1 || true; fi'
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Rover Video Publisher (libcamera -> SRT)
Description=Rover Video Publisher (libcamera -> RTSP/TCP)
After=network-online.target roverd.service
Wants=network-online.target
@@ -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
- external spectators are people outside of local network
- multitab protection mode
- allowed
- verified only
- not allowed
- snapshots
- non-turn snapshots
- on (you see snapshots when its not your turn)
- off (everyone gets full video all the time)
- non-local spectator snapshots
- on (external spectators are only allowed snapshots)
- off (all spectators get full video)
- non-turn video
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots after the user threshold is exceeded)
- live (rover non-active turn holders and PTZ non-operators can get full video)
- userThreshold (snapshots turn on when controllable users exceed this number)
- external spectator video
- snapshots (external spectators are only allowed snapshots)
- live (external spectators can get full video)
- external spectator access (new)
- off (no one 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
```
+93 -12
View File
@@ -51,8 +51,46 @@ barcodeGames:
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
# Example: http://192.168.0.86:8889/video
whepBaseUrl: "http://192.168.0.86:8889/video"
# Example: http://media-server.local:8889/video
whepBaseUrl: "http://media-server.local:8889/video"
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
# candidates. Include every public and LAN address browsers use to reach this server.
# The server generates MediaMTX's runtime configuration from this list; never edit a
# separate mediamtx.yml for a new installation.
additionalHosts:
- "rover.example.com"
- "media-server.local"
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"
# Disconnect rover video when its player is outside the viewport or the web
# page is in a background browser tab. Rover audio is a separate stream and
# remains connected. /mini intentionally keeps its existing always-warm video
# behavior regardless of this option.
pauseHiddenRoverVideo: false
# 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:
enabled: true
@@ -61,10 +99,15 @@ audioForward:
maxUploadBytes: 8388608
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
# Base multipliers (0.0 - 4.0) applied before any approved user's signed
# personal adjustment. The server clamps every final rover gain to this same
# hard multiplier range.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
# Approved users may move each personal slider this far below or above the
# base multiplier. Browser cookies store percentages, never raw multipliers.
maxPersonalAdjustmentPercent: 50
homeAssistant:
enabled: false
@@ -151,29 +194,35 @@ kinect:
# camera cache; it only gates browser-requested broadcasts.
captureCooldownMs: 10000
balanceBoard:
# The server installer always prepares Bluetooth and the kernel driver. This
# switch only starts the service and shows its small live-weight panel.
enabled: false
buttonBox:
enabled: false
barcodeScanner:
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 is optional. A token by itself never enables an external login.
enabled: false
token: "DISCORD_BOT_TOKEN"
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
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:
general: "123456789012345678"
announcements: "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"
humanAlerts: "123456789012345678"
roles:
@@ -195,3 +244,35 @@ socials:
url: "https://ko-fi.com/your-handle"
icon: "FaCoffee"
color: "#29ABE0"
# Optional trusted HTML card shown at the bottom of the desktop driver page's
# left column. Leave html empty (or omit this section) to hide the card. This
# content is sent to driver browsers without sanitization, so only place markup
# here that is controlled by the server operator.
driverAd:
title: "Advertisement"
html: |
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
</a>
# Optional passive fleet telemetry, history, and daily reporting. The collector
# observes existing server events and rover sensor frames but never participates
# in command, assignment, docking, or safety decisions.
fleetReports:
enabled: false
retention:
# Zero retains evidence indefinitely. Set explicit day counts on servers
# that prefer bounded storage over complete long-term history.
detailedDays: 0
minuteSamplesDays: 0
battery:
enabled: true
maximumIntegrationGapSeconds: 5
minimumCapacityTestDepthPercent: 60
discord:
enabled: true
sendAt: "08:00"
timezone: "America/New_York"
privacy:
retainChatBodies: true
+21
View File
@@ -0,0 +1,21 @@
<!--
Umami example for the optional provider-neutral rover analytics bridge.
Copy this file to analytics.html in the same data directory, replace the
example URLs and attributes, and restart the server. The server injects the
copied file into every web UI entry page; this example filename is not loaded
automatically.
-->
<script defer src="https://analytics.example.com/script.js" data-website-id="replace-with-website-id" data-domains="rover.example.com"></script>
<script defer src="https://analytics.example.com/recorder.js" data-website-id="replace-with-website-id" data-domains="rover.example.com" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<script>
window.roverAnalytics = {
track: function (name, data) {
window.umami?.track(name, data);
},
identify: function (data) {
window.umami?.identify(data);
},
};
</script>
+9
View File
@@ -28,6 +28,7 @@ require('./src/services/serverControlService');
require('./src/services/videoSessions');
require('./src/services/ptzCameraService');
require('./src/services/videoAuthService');
require('./src/services/mediaMtxService');
require('./src/services/videoSocketService');
require('./src/services/roomCameraService');
require('./src/services/roverSnapshotService');
@@ -46,8 +47,16 @@ require('./src/services/buttonBoxService');
require('./src/services/barcodeScannerService');
require('./src/services/barcodeGameService');
require('./src/services/kinectService');
require('./src/services/balanceBoardService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
// Fleet reporting starts after the rover and battery services so its passive
// subscriptions see fully decoded state without becoming an initialization
// dependency of either control path.
require('./src/services/fleetReportService');
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/httpServer');
+64 -39
View File
@@ -8,14 +8,14 @@ NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/downl
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
NEOLINK_BIN="/usr/local/bin/neolink"
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
MEDIAMTX_CONF_DIR="/etc/mediamtx"
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
SNAPSHOT_DIR="/var/lib/rover-snapshots"
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
if [[ $EUID -ne 0 ]]; then
echo "This installer must be run with sudo/root." >&2
@@ -30,8 +30,9 @@ fi
TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
@@ -134,7 +135,11 @@ dnf install -y \
gstreamer1-rtsp-server \
libfreenect \
libfreenect-devel \
libusb1-devel >/dev/null
libusb1-devel \
bluez \
wiiuse \
wiiuse-devel \
libcap >/dev/null
NODE_BIN="$(command -v node)"
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
@@ -166,12 +171,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
fi
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
echo " Building native Balance Board bridge..."
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
exit 1
fi
# Only this small audited bridge needs the management socket used for the
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
# front-button reconnects. Never grant either capability to node or the full
# multirover service executable.
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
fi
if [[ ! -f "$CONFIG_PATH" ]]; then
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
fi
# Bluetoothd remains responsible for discovery and the one-time bond, but its
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
# 0x13 before the Balance Board worker can listen for the board's front-button
# reconnect. This dedicated rover server gives those two HID listeners to the
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
# required by systemd before replacing the vendor unit's command in a drop-in.
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
EOF
chmod 0644 "$BLUETOOTH_OVERRIDE"
systemctl daemon-reload
systemctl enable bluetooth.service
systemctl restart bluetooth.service
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
@@ -220,51 +256,40 @@ if ! verify_google_tts_helper; then
verify_google_tts_helper
fi
mkdir -p "$MEDIAMTX_CONF_DIR"
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
exit 1
fi
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
exit 1
fi
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
rm -f "$MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
# Validate the new source of truth before disabling a working legacy service. The validator
# performs the same build and YAML serialization as server startup without opening listeners
# or leaving a process behind.
runuser -u "$TARGET_USER" -- env \
SERVER_CONFIG="$CONFIG_PATH" \
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
# MediaMTX used to run as its own systemd service with a hand-maintained config in
# /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two
# processes race for every media listener. Both commands are deliberately idempotent so an
# already-migrated server and a first-time installation follow the same path.
echo " Disabling legacy mediamtx.service"
systemctl disable --now mediamtx.service 2>/dev/null || true
rm -f "$MEDIAMTX_SERVICE"
rm -f /etc/mediamtx/mediamtx.yml
echo "[4/6] Writing systemd units..."
mkdir -p "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
mkdir -p "$REPLAY_SEGMENT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
cat > "$MEDIAMTX_SERVICE" <<EOF
[Unit]
Description=mediaMTX WebRTC Server
After=network-online.target
Wants=network-online.target
[Service]
User=$TARGET_USER
Group=$TARGET_USER
WorkingDirectory=$MEDIAMTX_CONF_DIR
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
cat > "$MULTIROVER_SERVICE" <<EOF
[Unit]
Description=Multi-Roomba Rover control server
After=network-online.target mediamtx.service
Wants=network-online.target
After=network-online.target bluetooth.service
Wants=network-online.target bluetooth.service
[Service]
User=$TARGET_USER
@@ -274,6 +299,7 @@ Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
RestartSec=2
@@ -283,21 +309,20 @@ SuccessExitStatus=130 143
WantedBy=multi-user.target
EOF
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
chmod 644 "$MULTIROVER_SERVICE"
echo "[5/6] Enabling services..."
systemctl daemon-reload
systemctl enable --now mediamtx.service
systemctl enable --now multirover.service
systemctl restart mediamtx.service
systemctl restart multirover.service
echo "[6/6] Done."
echo
echo "Services installed:"
echo " mediamtx.service (WebRTC fan-out)"
echo " multirover.service (Node.js control server)"
echo " multirover.service (Node.js control server with MediaMTX child)"
echo
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
echo "Kinect/libfreenect packages and udev permissions were installed."
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
-47
View File
@@ -1,47 +0,0 @@
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
logLevel: info
api: yes
apiAddress: 0.0.0.0:9997
metrics: yes
metricsAddress: 0.0.0.0:9998
pprof: no
pprofAddress: 127.0.0.1:9999
rtsp: no
rtmp: no
hls: no
webrtc: yes
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
webrtcICEServers2:
# Google public STUN (world-wide, very commonly used)
- url: stun:stun.l.google.com:19302
- url: stun:stun1.l.google.com:19302
- url: stun:stun2.l.google.com:19302
- url: stun:stun3.l.google.com:19302
- url: stun:stun4.l.google.com:19302
# Cloudflare STUN (anycast, global PoPs)
- url: stun:stun.cloudflare.com:3478
srt: yes
srtAddress: :9000
authMethod: http
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
paths:
all:
source: publisher
sourceOnDemand: no
# Rover Snapshot Writer
# Keep rover snapshots continuously updated while a rover video path is live.
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
runOnReadyRestart: yes
+1
View File
@@ -16,6 +16,7 @@
"home-assistant-js-websocket": "^3.1.2",
"js-yaml": "^4.1.1",
"kokoro-js": "^1.2.1",
"luxon": "^3.7.2",
"morgan": "^1.10.0",
"obscenity": "^0.4.6",
"ollama": "^0.6.3",
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

+6 -72
View File
@@ -4,82 +4,16 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/bitmap.png" />
<link rel="apple-touch-icon" href="/bitmap.png" />
<link rel="manifest" href="/manifest.json" />
<!-- The server renders this manifest so installed shortcuts use the local instance's configured branding. -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Mobile driving uses dense press controls, so the viewport opts out of browser zoom gestures that can steal touches from the controls. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
<script>
/*
Build-time analytics adapter for the rover UI.
React only calls window.roverAnalytics.track/identify. Keeping the Umami
adapter here means analytics can still be removed, replaced, or configured
by changing this injected file instead of rebuilding app logic around a
specific analytics provider.
*/
(function () {
var pendingCalls = [];
var flushTimer = null;
function callUmami(method, args) {
if (!window.umami || typeof window.umami[method] !== 'function') return false;
window.umami[method].apply(window.umami, args);
return true;
}
function flushPendingCalls() {
if (!pendingCalls.length) return;
if (!window.umami) return;
pendingCalls = pendingCalls.filter(function (call) {
return !callUmami(call.method, call.args);
});
if (!pendingCalls.length && flushTimer) {
window.clearInterval(flushTimer);
flushTimer = null;
}
}
function enqueue(method, args) {
if (callUmami(method, args)) return;
pendingCalls.push({ method: method, args: args });
/*
The React app may fire route/session events before Umami's deferred
script has executed. Queueing preserves those early events while still
letting the whole adapter no-op harmlessly if the script is blocked.
*/
if (!flushTimer) {
flushTimer = window.setInterval(flushPendingCalls, 500);
}
}
window.roverAnalytics = {
track: function (name, data) {
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
},
identify: function (data) {
enqueue('identify', [data || {}]);
},
};
window.addEventListener('load', flushPendingCalls);
})();
</script>
<!-- otterlytics testing for blocking local -->
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D5vPzVhj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BN3kEVFL.css">
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-BZ2ymoHR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css">
</head>
<body>
<div id="root"></div>
-18
View File
@@ -1,18 +0,0 @@
{
"name": "Multi Roomba Rover",
"short_name": "MRR",
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#020617",
"icons": [
{
"src": "/bitmap.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// MediaMTX Configuration Validator
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
const yaml = require('js-yaml');
const { loadConfig } = require('../src/helpers/configLoader');
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
const config = loadConfig();
const generated = buildMediaMtxConfig({
config,
serverPort: process.env.PORT || 8080,
snapshotWriterPath: process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
});
/*
Serializing is part of validation: it catches values that the builder accepted but js-yaml
cannot represent before the installer removes the previous service configuration.
*/
yaml.dump(generated, { noRefs: true, lineWidth: 120 });
process.stdout.write('MediaMTX server configuration is valid\n');
+148
View File
@@ -0,0 +1,148 @@
// 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',
pauseHiddenRoverVideo: false,
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 normalizeBoolean(value, fallback) {
/*
YAML booleans must stay real booleans. Treating strings such as "false" as
truthy would silently enable a bandwidth policy that the operator intended
to disable, so invalid values fall back to the documented server default.
*/
return typeof value === 'boolean' ? value : 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,
),
pauseHiddenRoverVideo: normalizeBoolean(
raw.pauseHiddenRoverVideo,
DEFAULT_BANDWIDTH_SAVINGS.pauseHiddenRoverVideo,
),
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,
};
+19
View File
@@ -46,10 +46,13 @@ function buildFeatureFlags(config = loadConfig()) {
const kinectConfig = config.kinect || {};
const buttonBoxConfig = config.buttonBox || {};
const barcodeScannerConfig = config.barcodeScanner || {};
const balanceBoardConfig = config.balanceBoard || {};
const barcodeGamesConfig = config.barcodeGames || {};
const socialsConfig = config.socials || {};
const interInstanceConfig = config.interInstance || {};
const ptzCameraConfig = config.ptzCamera || {};
const discordConfig = config.discord || {};
const fleetReportsConfig = config.fleetReports || {};
const homeAssistant = Boolean(
asBoolean(homeAssistantConfig.enabled) &&
asTrimmedString(homeAssistantConfig.url) &&
@@ -67,6 +70,10 @@ function buildFeatureFlags(config = loadConfig()) {
kinect: asBoolean(kinectConfig.enabled),
buttonBox: asBoolean(buttonBoxConfig.enabled),
barcodeScanner,
// The worker performs its own runtime availability reporting. Advertising
// the feature from the explicit config switch lets the UI show useful
// commissioning and hardware-error states even before a board is paired.
balanceBoard: asBoolean(balanceBoardConfig.enabled),
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
lift: Boolean(
homeAssistant &&
@@ -87,6 +94,18 @@ function buildFeatureFlags(config = loadConfig()) {
asTrimmedString(ptzCameraConfig.username) &&
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)),
// Fleet reports are deliberately controlled by one explicit server switch.
// Storage contents, Discord availability, or historical database files must
// never cause the reporting UI to appear on an installation that has not
// opted into the collector.
fleetReports: asBoolean(fleetReportsConfig.enabled),
};
}
+120
View File
@@ -0,0 +1,120 @@
// Site Metadata Helper
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
const { loadConfig } = require('./configLoader');
const DEFAULT_SITE_METADATA = Object.freeze({
name: 'Multi Roomba Rover',
shortName: 'Multi Roomba Rover',
description: 'Drive and watch remote rovers from your browser.',
accentColor: '#38bdf8',
backgroundColor: '#020617',
publicUrl: null,
});
const BACKGROUND_BLEND_AMOUNT = 0.15;
function asTrimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHexColor(value) {
const color = asTrimmedString(value).toLowerCase();
/*
Supporting both common CSS hex forms keeps the operator-facing setting
forgiving while still preventing arbitrary CSS from being injected into
generated HTML and SVG attributes.
*/
if (/^#[0-9a-f]{6}$/.test(color)) return color;
if (/^#[0-9a-f]{3}$/.test(color)) {
return `#${color.slice(1).split('').map((character) => character.repeat(2)).join('')}`;
}
return null;
}
function blendHexColors(baseColor, accentColor, accentAmount) {
const base = baseColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
const accent = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
/*
The profile color is deliberately only a tint. A full-strength profile
color could produce a glaring PWA launch screen, while this blend preserves
the application's established dark appearance and still makes each server
visually recognizable.
*/
const channels = base.map((channel, index) =>
Math.round(channel * (1 - accentAmount) + accent[index] * accentAmount),
);
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
}
function normalizePublicUrl(value) {
const candidate = asTrimmedString(value);
if (!candidate) return null;
/*
URL() helpfully repairs strings such as `http:192.168.0.1`, but preserving
that typo in public metadata would conceal a configuration mistake. Require
the conventional absolute URL form so the published address is explicit.
*/
if (!/^https?:\/\//i.test(candidate)) return null;
try {
const url = new URL(candidate);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
/*
Removing a trailing slash gives callers one stable base URL to combine
with paths. Invalid values are ignored instead of producing broken
canonical and social metadata on every page.
*/
return url.toString().replace(/\/$/, '');
} catch {
return null;
}
}
function getReadableAccentText(accentColor) {
const channels = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
const luminance = (channels[0] * 299 + channels[1] * 587 + channels[2] * 114) / 1000;
// A simple luminance split keeps the generated preview badge legible for both dark and light profile colors.
return luminance > 150 ? '#020617' : '#ffffff';
}
function resolveSiteMetadata(config = loadConfig()) {
const interInstance = config?.interInstance;
const profile = interInstance?.profile;
const profileName = asTrimmedString(profile?.name);
/*
A partially filled profile must not unexpectedly rename the site. The
inter-instance feature must be explicitly enabled and have a usable name
before any profile branding is applied; otherwise every value comes from
the coherent default set above.
*/
if (interInstance?.enabled !== true || !profileName) {
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
}
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
return {
name: profileName,
shortName: profileName,
description: asTrimmedString(profile.description) || DEFAULT_SITE_METADATA.description,
accentColor,
backgroundColor: blendHexColors(
DEFAULT_SITE_METADATA.backgroundColor,
accentColor,
BACKGROUND_BLEND_AMOUNT,
),
accentTextColor: getReadableAccentText(accentColor),
publicUrl: normalizePublicUrl(profile.publicUrl),
};
}
module.exports = {
DEFAULT_SITE_METADATA,
resolveSiteMetadata,
};
+7 -13
View File
@@ -1,10 +1,8 @@
// Reward Definition: Darkness
// 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 LIGHT_ENFORCE_TICK_MS = 3000;
let activeTimer = null;
let enforceLightsTimer = null;
let headlightLockUntil = 0;
function isHeadlightBlocked() {
@@ -16,10 +14,6 @@ function clearTimers() {
clearTimeout(activeTimer);
activeTimer = null;
}
if (enforceLightsTimer) {
clearInterval(enforceLightsTimer);
enforceLightsTimer = null;
}
}
async function forceAllLightsOff(ctx) {
@@ -55,7 +49,6 @@ async function stopDarkness(ctx, effect = {}) {
if (prevLockState === 'on' || prevLockState === 'off') {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darknessRestore',
forceApply: true,
targetState: prevLockState,
});
} else {
@@ -92,7 +85,6 @@ async function startDarkness(ctx, effect) {
try {
await ctx.setHomeAssistantLightsLockedOn(true, {
source: 'buttonbox:darkness',
forceApply: true,
targetState: 'off',
});
} catch (err) {
@@ -100,11 +92,13 @@ async function startDarkness(ctx, effect) {
}
ctx.saveEffect('darkness', effect);
enforceLightsTimer = setInterval(() => {
forceAllLightsOff(ctx).catch((err) => {
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
});
}, LIGHT_ENFORCE_TICK_MS);
/*
Darkness locks the room-light policy off and performs the initial off
command through setHomeAssistantLightsLockedOn above. It deliberately does
not keep a polling interval that re-forces Home Assistant entities off:
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(() => {
stopDarkness(ctx, effect).catch((err) => {
@@ -0,0 +1,75 @@
// Reward Definition: Green Mode
// Purpose: Enables the server-wide green theme and room effect for twenty minutes.
// Scope: Owns button-box timing/recovery while delegating the actual mode to greenModeService.
const DURATION_MS = 20 * 60 * 1000;
let activeTimer = null;
let unsubscribeGreenMode = null;
function clearRuntimeWatchers() {
if (activeTimer) {
clearTimeout(activeTimer);
activeTimer = null;
}
if (unsubscribeGreenMode) {
unsubscribeGreenMode();
unsubscribeGreenMode = null;
}
}
async function stopGreenMode(ctx) {
clearRuntimeWatchers();
await ctx.setGreenMode(false, { source: 'buttonbox:greenModeExpired' });
ctx.clearEffect('greenMode');
}
async function startGreenMode(ctx, effect = {}) {
clearRuntimeWatchers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
if (remaining <= 0) {
await stopGreenMode(ctx);
return;
}
await ctx.setGreenMode(true, { source: 'buttonbox:greenMode' });
ctx.saveEffect('greenMode', { endsAt });
/*
Access-mode changes disable green mode through greenModeService. Watching
that shared state transition lets the reward discard its persisted effect
immediately, so a restart cannot accidentally revive a reward that was
intentionally ended early.
*/
unsubscribeGreenMode = ctx.onGreenModeChange((enabled) => {
if (enabled) return;
clearRuntimeWatchers();
ctx.clearEffect('greenMode');
});
activeTimer = setTimeout(() => {
stopGreenMode(ctx).catch((err) => {
ctx.logger.warn('green mode reward stop failed', { error: err.message });
});
}, remaining);
}
module.exports = {
id: 'greenMode',
name: 'Green mode',
description: 'Makes the room and server green for 20 minutes.',
goal: 5,
async run(ctx) {
await startGreenMode(ctx, { endsAt: Date.now() + DURATION_MS });
},
async recover(ctx, effect) {
// Recovery must never manufacture a fresh twenty-minute window from a
// missing or corrupt persisted deadline. Treat it as expired and clean up.
if (!Number.isFinite(Number(effect?.endsAt))) {
await stopGreenMode(ctx);
return;
}
await startGreenMode(ctx, effect);
},
};
@@ -0,0 +1,57 @@
// Green Mode Reward Tests
// Purpose: Pins the five-press metadata and persisted timed-effect lifecycle.
// Scope: Uses a small context double; greenModeService behavior is tested through its public contract.
const test = require('node:test');
const assert = require('node:assert/strict');
const reward = require('./greenMode');
function createContext() {
const calls = [];
let changeListener = null;
return {
calls,
logger: { warn: () => {} },
setGreenMode: async (enabled, options) => {
calls.push({ type: 'set', enabled, source: options?.source });
return enabled;
},
saveEffect: (id, payload) => calls.push({ type: 'save', id, payload }),
clearEffect: (id) => calls.push({ type: 'clear', id }),
onGreenModeChange: (listener) => {
changeListener = listener;
return () => {
changeListener = null;
};
},
emitGreenModeChange: (enabled) => changeListener?.(enabled),
};
}
test('green mode reward requires five presses and starts a persisted effect', async () => {
const ctx = createContext();
assert.equal(reward.goal, 5);
await reward.run(ctx);
assert.deepEqual(ctx.calls[0], { type: 'set', enabled: true, source: 'buttonbox:greenMode' });
const saved = ctx.calls.find((call) => call.type === 'save');
assert.equal(saved?.id, 'greenMode');
assert.ok(saved?.payload?.endsAt > Date.now());
// Simulate an access-mode shutdown so the test also clears the reward's
// twenty-minute timer instead of leaving background work in the test process.
ctx.emitGreenModeChange(false);
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
test('invalid recovery state is cleared instead of starting a new duration', async () => {
const ctx = createContext();
await reward.recover(ctx, {});
assert.deepEqual(ctx.calls[0], {
type: 'set',
enabled: false,
source: 'buttonbox:greenModeExpired',
});
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
+2
View File
@@ -10,6 +10,7 @@ const discordPingEveryone = require('./definitions/discordPingEveryone');
const modeJam = require('./definitions/modeJam');
const assignmentRoulette = require('./definitions/assignmentRoulette');
const chatSpam = require('./definitions/chatSpam');
const greenMode = require('./definitions/greenMode');
const orderedRewards = [
dockPanic,
@@ -22,6 +23,7 @@ const orderedRewards = [
modeJam,
assignmentRoulette,
chatSpam,
greenMode,
];
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
+24 -29
View File
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { compareRoversForAssignment } = require('./roverRanking');
const socketRefs = new Map(); // socketId -> socket
const assignments = new Map(); // socketId -> roverId
@@ -96,8 +97,20 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
}
});
roverManager.managerEvents.on('rover', ({ action }) => {
if (action === 'removed' || action === 'upsert') {
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
if (action === 'removed') {
/*
The physical rover record is the authority for current driver ownership.
Once it disappears, every assignment that names it must be released and
run through ordinary placement again. Leaving those map entries intact
lets the same id become visible after reconnect without recreating its
driver membership, which is the exact stale-UI/video-auth split this
lifecycle boundary must prevent.
*/
reassignFromRover(roverId);
return;
}
if (action === 'upsert') {
reassignWaiting();
}
});
@@ -241,35 +254,17 @@ function pickRover(socket, options = {}) {
if (candidates.length === 0) {
return null;
}
const dockedRank = (rover) => {
if (!rover) return 0;
if (rover.docked === true) return -1;
if (rover.docked === false) return 1;
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
const docked = sensors?.chargingSources?.homeBase;
if (docked === true) return -1;
if (docked === false) return 1;
return 0;
};
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
const compare = (a, b) => {
const aEmpty = idleRank(a);
const bEmpty = idleRank(b);
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
const aDockRank = dockedRank(a);
const bDockRank = dockedRank(b);
if (aEmpty === 1 && aDockRank !== bDockRank) {
return bDockRank - aDockRank;
}
if (a.drivers.size !== b.drivers.size) {
return a.drivers.size - b.drivers.size;
}
return bDockRank - aDockRank;
};
candidates.sort(compare);
/*
Eligibility is resolved above, while this shared comparator owns only the
requested placement order: empty, undocked when empty, driver count, then
battery percentage.
Keeping those concerns separate prevents a ranking change from weakening
lock, private-rover, role, or mode access checks.
*/
candidates.sort(compareRoversForAssignment);
const best = candidates[0];
if (!best) return null;
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
const bestTier = candidates.filter((entry) => compareRoversForAssignment(entry, best) === 0);
if (!bestTier.length) return best;
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
}
@@ -0,0 +1,87 @@
// Rover assignment ranking
// Purpose: Ranks otherwise eligible rovers using the fleet's assignment priorities.
// Scope: Contains only deterministic comparison logic; access checks and the final random tie-break remain in assignmentService.
function readDockedState(rover) {
/*
The rover record normally exposes the server's canonical docked state. The
sensor fallback covers the short interval where telemetry has arrived but
the derived top-level field has not yet been synchronized. Unknown docking
state deliberately remains unknown instead of being treated as undocked.
*/
if (rover?.docked === true || rover?.docked === false) return rover.docked;
const sensors = rover?.lastSensor?.decoded || rover?.lastSensor?.sensors || null;
const homeBase = sensors?.chargingSources?.homeBase;
return homeBase === true || homeBase === false ? homeBase : null;
}
function driverCount(rover) {
/*
Production rover records use a Set. Returning a safe high-level count here
keeps ranking predictable for partially initialized records and makes the
comparator straightforward to exercise with small test fixtures.
*/
return Number.isFinite(rover?.drivers?.size) ? rover.drivers.size : 0;
}
function batteryPercentage(rover) {
/*
percentDisplay is the canonical server-normalized percentage used by the
rest of the application. Missing or invalid telemetry receives no invented
percentage; the comparator places unknown batteries after every known one.
*/
const percentage = rover?.batteryState?.percentDisplay;
return Number.isFinite(percentage) ? percentage : null;
}
function compareRoversForAssignment(left, right) {
/*
Spread drivers across the fleet before adding another person to an existing
rover queue. This comparison is deliberately independent of battery: a
small battery-percentage difference should never concentrate users on one
rover while another eligible rover has nobody assigned.
*/
const leftDrivers = driverCount(left);
const rightDrivers = driverCount(right);
const leftEmpty = leftDrivers === 0;
const rightEmpty = rightDrivers === 0;
if (leftEmpty !== rightEmpty) return leftEmpty ? -1 : 1;
/*
When both choices are empty, prefer the rover that is already away from its
dock. Docking state does not separate occupied rovers because queue balance
is more useful there, and an existing driver may already be handling the
rover's physical state. Unknown docking telemetry receives no undocked
preference rather than being guessed as ready.
*/
if (leftEmpty && rightEmpty) {
const leftUndocked = readDockedState(left) === false;
const rightUndocked = readDockedState(right) === false;
if (leftUndocked !== rightUndocked) return leftUndocked ? -1 : 1;
}
/*
For occupied rovers, queue length is the primary balancing signal. This is
intentionally evaluated before battery so a one-percent battery advantage
cannot cause every later user to pile onto the same rover.
*/
if (leftDrivers !== rightDrivers) return leftDrivers - rightDrivers;
const leftBattery = batteryPercentage(left);
const rightBattery = batteryPercentage(right);
const leftHasBattery = leftBattery != null;
const rightHasBattery = rightBattery != null;
if (leftHasBattery !== rightHasBattery) return leftHasBattery ? -1 : 1;
if (leftHasBattery && leftBattery !== rightBattery) return rightBattery - leftBattery;
/*
Returning zero is intentional. assignmentService randomly selects from the
complete best tier so stable Map insertion order cannot permanently favor a
rover whose emptiness, docking state, load, and battery are all equivalent.
*/
return 0;
}
module.exports = {
compareRoversForAssignment,
};
@@ -0,0 +1,77 @@
// Rover assignment ranking tests
// Purpose: Locks the operator-defined rover priority order against accidental comparator regressions.
// Scope: Tests pure ranking only; assignment side effects and access policy remain owned by their existing services.
const test = require('node:test');
const assert = require('node:assert/strict');
const { compareRoversForAssignment } = require('./roverRanking');
function rover({ id, docked, battery, drivers = 0 }) {
/*
Set size matches the production rover contract without introducing socket or
rover-manager dependencies into these focused ordering tests.
*/
return {
id,
docked,
batteryState: battery == null ? null : { percentDisplay: battery },
drivers: new Set(Array.from({ length: drivers }, (_, index) => `${id}-driver-${index}`)),
};
}
function rankedIds(entries) {
return entries.sort(compareRoversForAssignment).map((entry) => entry.id);
}
test('an empty rover outranks an occupied rover regardless of battery or docking state', () => {
const result = rankedIds([
rover({ id: 'occupied-high', docked: false, battery: 100, drivers: 1 }),
rover({ id: 'docked-empty', docked: true, battery: 20 }),
]);
assert.deepEqual(result, ['docked-empty', 'occupied-high']);
});
test('an undocked rover is preferred when both rovers are empty', () => {
const result = rankedIds([
rover({ id: 'docked-high', docked: true, battery: 100 }),
rover({ id: 'undocked-low', docked: false, battery: 20 }),
]);
assert.deepEqual(result, ['undocked-low', 'docked-high']);
});
test('lowest driver count ranks occupied rovers before battery percentage', () => {
const result = rankedIds([
rover({ id: 'busy-high', docked: false, battery: 100, drivers: 4 }),
rover({ id: 'quieter-low', docked: false, battery: 20, drivers: 1 }),
]);
assert.deepEqual(result, ['quieter-low', 'busy-high']);
});
test('battery percentage ranks rovers after availability and load are equal', () => {
const result = rankedIds([
rover({ id: 'low', docked: false, battery: 35, drivers: 1 }),
rover({ id: 'high', docked: false, battery: 90, drivers: 1 }),
rover({ id: 'middle', docked: false, battery: 60, drivers: 1 }),
]);
assert.deepEqual(result, ['high', 'middle', 'low']);
});
test('known battery percentage outranks missing battery telemetry', () => {
const result = rankedIds([
rover({ id: 'unknown', docked: true, battery: null }),
rover({ id: 'known', docked: true, battery: 5 }),
]);
assert.deepEqual(result, ['known', 'unknown']);
});
test('exactly equivalent rovers remain tied for random selection by assignmentService', () => {
const left = rover({ id: 'left', docked: false, battery: 80, drivers: 1 });
const right = rover({ id: 'right', docked: false, battery: 80, drivers: 1 });
assert.equal(compareRoversForAssignment(left, right), 0);
assert.equal(compareRoversForAssignment(right, left), 0);
});
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
buildWhipUrl,
videoSessions,
startSilenceWriter,
isMuted,
verificationEvents,
} = deps;
verificationEvents.on('change', ({ socketId } = {}) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (!socket || !isMuted(socket)) return;
/*
Permission checks stop new muted audio, but an upload or microphone can
already be live when moderation changes. Stop only streams owned by this
socket so muting does not disturb another driver's audio or unrelated
server-generated sounds.
*/
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
if (ownerSocketId === socketId) {
stopWhipForRover(roverId, 'owner_muted');
}
}
workers.forEach((worker, roverId) => {
if (worker?.contentKind === 'upload' && worker.activeOwnerSocketId === socketId) {
logger.info('Stopping uploaded audio because its owner was muted', { roverId, socketId });
startSilenceWriter(roverId);
}
});
});
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (!roverId) return;
if (action === 'removed') {
@@ -8,7 +8,7 @@ const logger = require('../../globals/logger').child('audioForwardService');
const { loadConfig } = require('../../helpers/configLoader');
const roverManager = require('../roverManager');
const turnService = require('../turnService');
const { isVerified } = require('../verificationService');
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
const videoSessions = require('../videoSessions');
const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine');
@@ -62,6 +62,7 @@ function getAudioForwardState() {
const audioForwardPolicy = createAudioForwardPolicy({
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
@@ -141,6 +142,8 @@ registerAudioForwardHooks({
buildWhipUrl,
videoSessions,
startSilenceWriter,
isMuted,
verificationEvents,
});
registerChargeCompleteSound({
@@ -4,6 +4,7 @@
function createAudioForwardPolicy(deps) {
const {
isVerified,
isMuted,
roverManager,
turnService,
streamSuffix,
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
function ensureAudioForwardPermission(socket, roverId) {
ensureVipVerified(socket);
if (isMuted(socket)) {
throw new Error('Muted');
}
if (!roverManager.isDriver(roverId, socket)) {
throw new Error('Audio forwarding is only allowed on your own rover');
}
@@ -26,25 +30,13 @@ function createAudioForwardPolicy(deps) {
}
}
function forcePublishStreamMode(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (!/[?&]streamid=#!::/.test(value)) return value;
if (/,m=publish\b/.test(value)) return value;
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
}
function resolveForwardUrl(roverId) {
const record = roverManager.rovers.get(roverId);
// Rovers listen to the playback stream with a request/read URL. The VIP
// upload path needs to publish into that same stream, so the configured
// nested playback URL is converted to publish mode below.
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
if (configured) return forcePublishStreamMode(configured);
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
roverId + streamSuffix,
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
/*
The server publishes to its own MediaMTX child, so loopback is the stable and correct
route regardless of which hostname a rover uses to reach this machine. RTSP uses the
same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
*/
return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
}
function resolveForwardPathId(roverId) {
@@ -0,0 +1,32 @@
// Audio Forward Policy Tests
// Purpose: Verifies that mute blocks user-owned forwarding without changing ordinary driver authorization.
// Scope: Exercises the pure permission policy with small injected role, rover, and turn doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createAudioForwardPolicy } = require('./policy');
function createPolicy({ verified = true, muted = false, driver = true, canDrive = true } = {}) {
return createAudioForwardPolicy({
isVerified: () => verified,
isMuted: () => muted,
roverManager: { isDriver: () => driver },
turnService: { canDrive: () => canDrive },
streamSuffix: '-fwd',
mediaConfig: {},
});
}
test('rejects audio forwarding for a muted verified driver', () => {
const policy = createPolicy({ muted: true });
assert.throws(() => policy.ensureAudioForwardPermission({}, 'rover'), /Muted/);
});
test('preserves normal audio forwarding for an unmuted verified driver', () => {
const policy = createPolicy();
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
});
test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
const policy = createPolicy();
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
});
@@ -86,7 +86,7 @@ function createAudioForwardWorkerEngine(deps) {
exited = true;
};
// ChildProcess.killed only means Node successfully sent a signal, not that
// ffmpeg actually exited. Track the real exit event so FIFO/SRT hangs still
// ffmpeg actually exited. Track the real exit event so FIFO/publisher hangs still
// get escalated to SIGKILL instead of making systemd wait for its timeout.
proc.once('exit', markExited);
try {
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
'-muxpreload',
'0',
'-f',
'mpegts',
'rtsp',
/*
The MediaMTX listener accepts RTSP over TCP only. Pinning it here makes the server's
own publisher follow the same reliable transport contract as every rover publisher.
*/
'-rtsp_transport',
'tcp',
outputUrl,
];
}
@@ -0,0 +1,62 @@
// Audio Adjustment Math
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
const ADJUSTMENT_FIELDS = [
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
];
const MIN_GAIN = 0;
const MAX_GAIN = 4;
const MIN_ADJUSTMENT_PERCENT = -100;
const MAX_ADJUSTMENT_PERCENT = 100;
function clampGain(value, fallback = 1) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
}
function clampMaximumAdjustmentPercent(value, fallback = 50) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
}
function clampAdjustmentPercent(value, maximum = 0) {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
const limit = clampMaximumAdjustmentPercent(maximum, 0);
return Math.round(Math.max(-limit, Math.min(limit, number)));
}
function normalizeAdjustments(raw = {}, maximum = 0) {
const normalized = {};
ADJUSTMENT_FIELDS.forEach(({ percentKey }) => {
normalized[percentKey] = clampAdjustmentPercent(raw?.[percentKey], maximum);
});
return normalized;
}
function applyAdjustments(baseLevels = {}, adjustments = {}) {
const effective = {};
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
const base = clampGain(baseLevels?.[gainKey], 0);
const percentage = Math.max(MIN_ADJUSTMENT_PERCENT, Math.min(MAX_ADJUSTMENT_PERCENT, Number(adjustments?.[percentKey]) || 0));
effective[gainKey] = clampGain(base * (1 + percentage / 100), 0);
});
return effective;
}
module.exports = {
ADJUSTMENT_FIELDS,
MIN_GAIN,
MAX_GAIN,
MIN_ADJUSTMENT_PERCENT,
MAX_ADJUSTMENT_PERCENT,
clampGain,
clampMaximumAdjustmentPercent,
clampAdjustmentPercent,
normalizeAdjustments,
applyAdjustments,
};
@@ -0,0 +1,37 @@
// Audio Adjustment Math Tests
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
// Scope: Covers only the pure rules used by audioLevelsService.
const test = require('node:test');
const assert = require('node:assert/strict');
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
test('the configured range is a whole percentage from zero through one hundred', () => {
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
assert.equal(clampMaximumAdjustmentPercent(500), 100);
});
test('each browser percentage is clamped equally in both directions', () => {
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
hornPercent: -40,
ttsPercent: 10,
forwardPercent: 40,
});
});
test('signed percentages adjust each server base gain', () => {
assert.deepEqual(
applyAdjustments(
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
{ hornPercent: -25, ttsPercent: 25, forwardPercent: 40 },
),
{ hornGain: 0.75, ttsGain: 2.5, forwardGain: 0.7 },
);
});
test('effective gains remain inside the rover hard bounds', () => {
assert.deepEqual(
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
);
});
+212 -9
View File
@@ -7,35 +7,49 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin } = require('../roleService');
const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager');
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
const { issueCommand } = require('../commandService');
const {
ADJUSTMENT_FIELDS,
clampGain,
clampMaximumAdjustmentPercent,
normalizeAdjustments,
applyAdjustments,
} = require('./gainMath');
const audioLevelsEvents = new EventEmitter();
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
configuredDefaults.maxPersonalAdjustmentPercent,
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
),
};
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(4, num));
}
function normalizeStore(raw = {}) {
return {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
raw.maxPersonalAdjustmentPercent,
DEFAULTS.maxPersonalAdjustmentPercent,
),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
};
}
@@ -46,6 +60,11 @@ function loadState() {
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
state = normalizeStore(raw);
if (Object.prototype.hasOwnProperty.call(raw, 'userGainCaps')) {
// Rewrite once so the retired VIP-cap object does not linger beside the
// new percentage range and confuse future operator inspection.
persistState(state);
}
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load audio levels store', err.message);
@@ -71,18 +90,79 @@ function getAudioLevels() {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
updatedAt: current.updatedAt,
updatedBy: current.updatedBy,
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
};
}
function emitChange(reason = 'update') {
function emitChange(reason = 'update', extra = {}) {
audioLevelsEvents.emit('change', {
reason,
levels: getAudioLevels(),
...extra,
});
}
function getAdminLimits() {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
};
}
function canUsePersonalAdjustments(socket) {
if (isAdmin(socket)) return true;
const userId = getUserIdForSocket(socket);
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
}
function getAdjustmentsForSocket(socket) {
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
}
function getEffectiveLevelsForSocket(socket) {
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
}
/*
The rover applies gain as three ALSA master controls, so only one set of gains
can be live per rover at a time. That is not a limitation in practice: horn,
TTS, and mic forwarding are all restricted to the socket currently holding
audio control, so pushing that socket's resolved gains gives genuinely
per-user volume. When nobody owns audio the global admin gains apply.
*/
function resolveAudioOwnerSocket(roverId) {
const record = roverManager.rovers.get(roverId);
if (!record) return null;
const driverIds = Array.from(record.drivers || []);
if (!driverIds.length) return null;
// Required lazily: turnService reaches back into roverManager during startup.
let activeSocketId = null;
try {
activeSocketId = require('../turnService').getActiveDrivers()[roverId] || null;
} catch (err) {
logger.warn('Failed to resolve active driver for audio levels', roverId, err.message);
}
const chosenId = activeSocketId && driverIds.includes(activeSocketId)
? activeSocketId
: (driverIds.length === 1 ? driverIds[0] : null);
if (!chosenId) return null;
return io.sockets.sockets.get(chosenId) || null;
}
function resolveLevelsForRover(roverId) {
const owner = resolveAudioOwnerSocket(roverId);
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
}
function pushLevelsToRover(roverId) {
if (!roverId) return;
const record = roverManager.rovers.get(roverId);
@@ -90,7 +170,7 @@ function pushLevelsToRover(roverId) {
try {
issueCommand(roverId, {
type: 'audioLevels',
audioLevels: getAudioLevels(),
audioLevels: resolveLevelsForRover(roverId),
});
} catch (err) {
logger.warn('Failed to push audio levels to rover', roverId, err.message);
@@ -105,6 +185,11 @@ function pushLevelsToAllRovers() {
});
}
function pushLevelsForSocket(socket) {
if (!socket) return;
roverManager.getRoversForSocket(socket.id).forEach((roverId) => pushLevelsToRover(roverId));
}
function setAudioLevels(input = {}, actor = null) {
const current = loadState();
const next = {
@@ -121,12 +206,94 @@ function setAudioLevels(input = {}, actor = null) {
return getAudioLevels();
}
function setMaxPersonalAdjustmentPercent(value, actor = null) {
const current = loadState();
const next = {
...current,
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
adjustmentRangeUpdatedAt: Date.now(),
adjustmentRangeUpdatedBy: actor,
};
persistState(next);
/*
A narrower range must take effect immediately for current drivers rather
than leaving an out-of-range multiplier active until their next turn.
*/
pushLevelsToAllRovers();
emitChange('personal_adjustment_range_set');
return loadState().maxPersonalAdjustmentPercent;
}
function setSocketAdjustments(socket, input = {}) {
socket.data = socket.data || {};
// Store only server-normalized percentages on the transport. The cookie is a
// browser preference, while permission and range enforcement remain here.
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
pushLevelsForSocket(socket);
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
return getAudioAdjustmentStateForSocket(socket);
}
/*
The client receives the percentages the server accepted, the permitted range,
and the resulting multipliers. This keeps the UI honest even when a cookie was
edited or an administrator changed permission while the browser was online.
*/
function getAudioAdjustmentStateForSocket(socket) {
const allowed = canUsePersonalAdjustments(socket);
const maximum = loadState().maxPersonalAdjustmentPercent;
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
return {
values,
allowed,
maxAdjustmentPercent: maximum,
effective: applyAdjustments(getAdminLimits(), values),
baseLevels: getAdminLimits(),
};
}
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
if (action === 'upsert' && roverId) {
pushLevelsToRover(roverId);
}
});
/*
Whoever owns a rover's audio determines which gains are live, so the rover has
to be re-pushed whenever that ownership moves: joining or leaving a rover, and
every turn rotation.
*/
roverManager.managerEvents.on('driver', ({ roverId } = {}) => {
if (roverId) pushLevelsToRover(roverId);
});
setImmediate(() => {
try {
require('../turnService').turnEvents.on('queue', ({ roverId } = {}) => {
if (roverId) pushLevelsToRover(roverId);
});
} catch (err) {
logger.warn('Failed to subscribe to turn changes for audio levels', err.message);
}
});
identityEvents.on('change', ({ reason, userId } = {}) => {
if (!userId || !['permission_granted', 'permission_revoked', 'identify'].includes(reason)) return;
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId) return;
pushLevelsForSocket(socket);
// Permission changes alter both effective rover output and the controls the
// browser may use, so each affected connection receives a fresh session.
emitChange('personal_adjustment_permission_changed', { scope: 'socket', socketId: socket.id });
});
});
roleEvents.on('change', ({ socket } = {}) => {
// Administrators implicitly have this capability, so login/logout can change
// the effective adjustment even though no database permission row changed.
if (socket) pushLevelsForSocket(socket);
});
io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() });
@@ -144,13 +311,49 @@ io.on('connection', (socket) => {
cb({ error: err.message });
}
});
socket.on('audioLevels:setPersonalAdjustmentRange', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
cb({ success: true, maxPersonalAdjustmentPercent });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
try {
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
try {
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
} catch (err) {
cb({ error: err.message });
}
});
});
loadState();
module.exports = {
ADJUSTMENT_FIELDS,
PERSONAL_ADJUSTMENT_PERMISSION,
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
getAudioLevels,
setAudioLevels,
setMaxPersonalAdjustmentPercent,
setSocketAdjustments,
getEffectiveLevelsForSocket,
getAudioAdjustmentStateForSocket,
pushLevelsToRover,
audioLevelsEvents,
};
+107 -1
View File
@@ -8,9 +8,20 @@ const { loadConfig } = require('../../helpers/configLoader');
const { clearLockdownTimer } = require('../lockdownGuard');
const { getMode, MODES } = require('../modeManager');
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 admins = config.admins || [];
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
function findAdmin(username) {
return admins.find((admin) => admin.username === username);
@@ -36,9 +47,91 @@ function isLockdownAdmin(socket) {
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) => {
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);
logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole });
@@ -51,6 +144,13 @@ io.on('connection', (socket) => {
const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id };
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 });
clearLockdownTimer(socket);
logger.info('Login success', socket.id, role);
@@ -63,6 +163,12 @@ io.on('connection', (socket) => {
function handleRoleChange({ role } = {}, cb = () => {}) {
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);
socket.emit('auth:role', { 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,584 @@
// 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 === 'device-detected') {
// Preserve the worker's exact identification stage instead of leaving the
// panel apparently unchanged when an adapter sees only the board's address.
// This is intentionally not a feed alert because ambient unresolved devices
// can appear during commissioning and the state is already visible locally.
updateStatus(
'identifying',
message.error || 'Bluetooth device detected; checking whether it is the Balance 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
@@ -22,6 +22,9 @@ function createButtonBoxCore(deps) {
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode,
isGreenModeEnabled,
onGreenModeChange,
store,
} = deps;
@@ -215,6 +218,11 @@ function createButtonBoxCore(deps) {
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
setHomeAssistantLightsLockedOn: (next, options = {}) =>
setHomeAssistantLightsLockedOn(next, options),
// Rewards receive the standalone feature boundary rather than reaching
// into Home Assistant or duplicating green-mode state and alerts.
setGreenMode: (next, options = {}) => setGreenMode(next, options),
isGreenModeEnabled: () => isGreenModeEnabled(),
onGreenModeChange: (listener) => onGreenModeChange(listener),
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
};
@@ -24,6 +24,7 @@ const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { createButtonBoxStore } = require('./store');
const { createButtonBoxCore } = require('./core');
const { registerButtonBoxRoute } = require('./httpRoute');
const greenModeService = require('../greenModeService');
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
@@ -61,6 +62,14 @@ const core = createButtonBoxCore({
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode: greenModeService.setEnabled,
isGreenModeEnabled: greenModeService.isEnabled,
// Return an explicit cleanup function so timed rewards can stop observing
// the global service when they expire, rerun, or are recovered.
onGreenModeChange: (listener) => {
greenModeService.greenModeEvents.on('change', listener);
return () => greenModeService.greenModeEvents.off('change', listener);
},
store,
});
+47 -18
View File
@@ -3,12 +3,13 @@
// Scope: Owns message validation pipeline and typed outbound message construction.
const logger = require('../../globals/logger').child('chatService');
const { getRole } = require('../roleService');
const { isDeterred, isMuted } = require('../verificationService');
const { withinRateLimit } = require('./state');
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
const { broadcastMessage, broadcastTyping } = require('./broadcast');
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
const { runChatTextCommand } = require('./textCommands');
const { isTextCommand, runChatTextCommand } = require('./textCommands');
function createHandlers({ sendSystemMessage }) {
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return cb({ error: 'Message required' });
/*
Mute is narrower than deterrence: the socket may continue driving and
using ordinary features, but its message must stop before broadcast,
command parsing, TTS, or any other chat-derived side effect occurs.
*/
if (isMuted(socket)) return cb({ error: 'Muted' });
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
// This service no longer enforces a character-count ceiling for chat text.
// The chat layer only rejects empty, rate-limited, or moderated content so
@@ -37,37 +44,59 @@ function createHandlers({ sendSystemMessage }) {
});
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
const deterred = isDeterred(socket);
/*
Deterred users retain text chat, but chat must not become an indirect
hardware-control path. Suppress the rover typing note and TTS while still
constructing and broadcasting the same visible message as everyone else.
*/
if (!deterred) {
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
}
if (isPrivateClosedRoverId(message.roverId)) {
// Private-closed chat does not broadcast the text, so TTS is the only
// delivery path. Use the same Google speech default as normal chat when
// the sender did not provide explicit TTS settings.
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
maybeSpeak(socket, message, forcedTts);
if (!deterred) {
maybeSpeak(socket, message, forcedTts);
}
cb({ success: true, privateOnly: true });
return;
}
broadcastMessage(message);
maybeSendAccessNotice(message, sendSystemMessage);
maybeSpeak(socket, message, ttsOptions);
try {
// Commands sent from site chat should still be visible as normal chat
// messages. Running the command after broadcast preserves the user-visible
// transcript while keeping permissions and command execution entirely on
// the server.
const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
cb({ success: true, command: ranCommand });
return;
} catch (err) {
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
return;
if (!deterred) {
maybeSpeak(socket, message, ttsOptions);
}
cb({ success: true });
/*
Command-shaped text from a deterred user remains ordinary visible chat.
Reporting command=false prevents the client from implying that the server
accepted an action, and the command router is never invoked.
*/
const command = !deterred && isTextCommand(clean);
// Chat delivery is complete once validation, broadcast, and local side
// effects above have succeeded. A command may wait on Home Assistant,
// hardware, replay preparation, or an external transport, so tying the
// socket acknowledgement to command completion leaves the browser's send
// promise pending and makes its input state appear stuck. Acknowledge now;
// command replies continue through the normal Rover bot message stream.
cb({ success: true, command });
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 }) {
+20 -1
View File
@@ -3,6 +3,7 @@
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
const io = require('../../globals/io');
const { subscribe } = require('../eventBus');
const { isDeterred, isMuted } = require('../verificationService');
const { typingBySocket } = require('./state');
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
const { broadcastTyping } = require('./broadcast');
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
socket.emit('chat:init', history);
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
socket.on('chat:typing', (payload = {}) => {
/*
A muted typing packet must not leak presence or produce rover notes.
Clearing any prior state also removes a typing indicator that began
immediately before an administrator applied the mute.
*/
if (isMuted(socket)) {
const wasTyping = typingBySocket.delete(socket.id);
const roverId = resolveRoverId(socket?.id);
if (wasTyping && !isPrivateClosedRoverId(roverId)) {
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
}
return;
}
const isTyping = Boolean(payload?.isTyping);
const wasTyping = typingBySocket.get(socket.id);
if (isTyping) {
typingBySocket.set(socket.id, true);
if (!wasTyping) {
/*
The typing indicator is part of chat and remains available to a
deterred user. The rover note is a physical side effect, however, so
text-only deterrence suppresses that note without changing presence.
*/
if (!wasTyping && !isDeterred(socket)) {
const roverId = resolveRoverId(socket?.id);
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
}
+41 -20
View File
@@ -10,30 +10,44 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const greenModeService = require('../greenModeService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { isFeatureEnabled } = require('../../helpers/features');
const {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService');
const {
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
} = require('../identityService');
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
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 {
buildReplayJobId,
buildReplayTitle,
createReplaySourceResolver,
} = require('../discordBotService/replayWorkflow');
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
function isTextCommand(text) {
const clean = String(text || '').trim();
return clean.toLowerCase() === 'ts' || /^rs(?:\s|$)/i.test(clean);
return parseCommandText(text, config).matched;
}
function sanitizeMentions(text) {
@@ -70,12 +84,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
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);
if (resolved?.error) {
await message.reply({ content: resolved.error });
@@ -99,7 +107,6 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
type: 'replay.requested',
payload: {
jobId,
channelId,
requester,
title: '',
includeSidebar: true,
@@ -113,18 +120,18 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
};
}
function createChatCommandMessage({ socket, text, sendSystemMessage }) {
function createChatCommandRequest({ socket, text, sendSystemMessage }) {
const nickname = buildRequesterLabel(socket);
return {
content: String(text || '').trim(),
author: {
actor: {
bot: false,
id: socket.id,
username: nickname,
},
member: {
nickname,
label: nickname,
isAdmin: isAdmin(socket),
isLockdownAdmin: isLockdownAdmin(socket),
},
transport: 'web-chat',
reply: async (payload) => {
const response = sanitizeMentions(commandReplyToText(payload));
if (!response) return null;
@@ -139,8 +146,8 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// keeps ordinary chatService initialization from changing the service boot
// order, while still letting `rs replay` use the existing replay pipeline.
const replayApi = require('../replayEngineV2');
const message = createChatCommandMessage({ socket, text, sendSystemMessage });
const commands = createCommandHandlers({
const message = createChatCommandRequest({ socket, text, sendSystemMessage });
const commandDependencies = {
logger: null,
client: null,
io,
@@ -168,6 +175,10 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig: () => null,
setGuildConfig: () => null,
removeGuildConfig: () => null,
@@ -176,16 +187,26 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
discordConfig,
siteUrl: String(discordConfig.siteUrl || ''),
config,
createReplayTextCommand: createWebReplayTextCommand(socket, sendSystemMessage, replayApi),
});
};
commandDependencies.transportHandlers = createWebTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
// Let the shared router perform normal command permission checks. Site chat
// has already broadcast the user's command text, so command replies become a
+128 -2
View File
@@ -2,13 +2,21 @@
// Purpose: Defines the command Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { v4: uuidv4 } = require('uuid');
const EventEmitter = require('events');
const io = require('../../globals/io');
const roverManager = require('../roverManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { isDeterred } = require('../verificationService');
const { isDeterred, isMuted } = require('../verificationService');
const logger = require('../../globals/logger').child('commandService');
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
const homeAssistantService = require('../homeAssistantService');
const overcurrentProtectionService = require('../overcurrentProtectionService');
// Command observations are intentionally separate from the global event bus.
// Drive and motor commands can run at control-loop frequency, and publishing
// every packet onto the logging event bus would manufacture noise. Optional
// observers can aggregate this emitter without changing command delivery.
const commandEvents = new EventEmitter();
const pendingCommands = new Map(); // id -> { roverId }
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
@@ -93,6 +101,31 @@ function issueCommand(roverId, payload) {
return id;
}
/*
The protection service owns decisions about when a held command must be
resent at a lower output. Injecting this raw transport function keeps those
resends on the same rover websocket path as every other server command while
avoiding a circular dependency from the protection service back into this
socket-facing module.
*/
overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
const blockedUntil = driveCooldowns.get(roverId);
const safetyCooldownActive = blockedUntil && Date.now() < blockedUntil;
if (safetyCooldownActive && getCommandMotionMagnitude(payload?.type, payload) > 0) {
/*
Private-rover and dock safety own the existing command cooldown map. A
rate-limited protection resend must respect those independent systems;
otherwise this new service could restart drive or brushes immediately
after an unrelated safety feature deliberately stopped them. Returning
false tells the protection service to retry after the cooldown instead of
recording an output that never reached the rover.
*/
return false;
}
issueCommand(roverId, payload);
return true;
});
function handleAck(msg) {
const pending = pendingCommands.get(msg.id);
if (!pending) return;
@@ -104,6 +137,38 @@ function handleAck(msg) {
status: msg.status || 'ok',
error: msg.error,
});
commandEvents.emit('observation', {
ts: Date.now(),
roverId: pending.roverId,
type: pending.type,
commandId: msg.id,
outcome: msg.error ? 'failed' : 'acknowledged',
latencyMs: Date.now() - pending.ts,
status: msg.status || 'ok',
error: msg.error || null,
});
}
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 = {}) {
@@ -189,6 +254,7 @@ module.exports = {
handleAck,
getRecentDriveActivity,
setDriveCooldown,
commandEvents,
};
io.on('connection', (socket) => {
@@ -204,7 +270,7 @@ io.on('connection', (socket) => {
if (type === 'audioLevels') {
throw new Error('audioLevels command is service-managed');
}
const payload = data ? { ...data } : {};
let payload = data ? { ...data } : {};
if (type === 'headlight' && isHeadlightBlocked()) {
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
reply({ ignored: true, reason: 'darknessActive' });
@@ -226,6 +292,14 @@ io.on('connection', (socket) => {
if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized');
}
/*
Both structured song commands and raw Open Interface song payloads
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
and any future browser beeper without affecting unrelated driving.
*/
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
throw new Error('Muted');
}
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system
@@ -269,7 +343,31 @@ io.on('connection', (socket) => {
});
}
}
if (type === 'drive' || type === 'motors') {
/*
Role is supplied at the command boundary because telemetry does not
identify the operator who produced the active motor intent. Admin and
lockdown commands therefore enter the service explicitly bypassed;
they are recorded for status visibility but are never scaled, blocked,
or countermanded by a later sensor frame.
*/
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
bypassed: isAdminSocket,
});
}
const id = issueCommand(roverId, { type, ...payload });
commandEvents.emit('observation', {
ts: Date.now(),
roverId: String(roverId),
type,
commandId: id,
outcome: 'issued',
socketId: socket.id,
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
// commands can carry arbitrary content. Their structured type/outcome
// supplies analytics without accidentally persisting secret material.
});
logger.info('Queued command', socket.id, roverId, type);
if (shouldRecordTurnActivity(type, payload)) {
try {
@@ -282,12 +380,40 @@ io.on('connection', (socket) => {
reply({ id });
} catch (err) {
logger.warn('Command rejected', socket.id, err.message);
commandEvents.emit('observation', {
ts: Date.now(),
roverId: roverId ? String(roverId) : null,
type: type || 'unknown',
outcome: 'rejected',
socketId: socket.id,
error: err.message,
});
reply({ error: err.message });
}
}
socket.on('command', 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', () => {
@@ -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.
// Scope: Manages bridge channel, mode, and webhook provisioning.
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
// 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) {
if (isAdminUser(message.author.id)) return true;
if (!message.guild || !message.member) return false;
@@ -1,58 +0,0 @@
// Discord Deter Command
// Purpose: Handles deterrence moderation commands for lockdown admins.
// Scope: Supports list, ban, and unban subcommands.
const { mask, resolveIdentitySelector } = require('./resolvers');
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, isLockdownAdminUser, sanitizeMentions, discordConfig }) {
// Moderation errors often get copied into Discord chat, so they should show
// the configured bot prefix instead of the legacy default when several bots
// are present in the same server.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
return async function handleDeterCommand(message, tokens) {
if (!isLockdownAdminUser(message.author?.id)) {
await message.reply({ content: 'Only lockdown admins can manage deterred users.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'list') {
const users = listDeterredUsers();
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
}
if (action === 'ban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
}
// Ban reasons were deliberately removed from the command grammar. The
// full remaining text is now always the selector, which lets lockdown
// admins deter multi-word nicknames without quoting or delimiter rules.
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
const deterred = deterUser(stableSelector, { actor: message.author?.id || null });
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to deter user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
if (action === 'unban') {
const selector = tokens.join(' ').trim();
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter unban <id|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
try {
const resolved = resolveIdentitySelector(selector, listDeterredUsers(), { includeId: true });
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);
return message.reply({ content: sanitizeMentions(`Removed deterrence for ${removed.nickname || 'unknown'} (${mask(removed.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
}
}
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, or \`${commandPrefix} deter unban <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
};
}
module.exports = { createDeterCommand };
@@ -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 };
@@ -1,76 +0,0 @@
// Discord Lights Command
// Purpose: Handles admin room-light lock policy commands from Discord and web chat.
// Scope: Delegates all actual Home Assistant policy behavior to homeAssistantService.
function describeLightPolicy(lightPolicy = {}) {
// The HA service exposes both the newer explicit lockState and the older
// lockedOn boolean. Prefer lockState because it can distinguish locked-on
// from locked-off, but keep lockedOn as a defensive fallback for any caller
// that passes an older or partial policy object.
const lockState = lightPolicy?.lockState || (lightPolicy?.lockedOn ? 'on' : null);
if (lockState === 'on') return 'Room lights are locked on.';
if (lockState === 'off') return 'Room lights are locked off.';
return 'Room lights are unlocked.';
}
function createLightsCommand({ homeAssistantService, sanitizeMentions, discordConfig }) {
// The HA policy behavior is prefix-agnostic; this value is only used so
// invalid-command guidance points admins at this bot instance's namespace.
const commandPrefix = String(discordConfig?.commandPrefix || 'rs').trim() || 'rs';
return async function handleLightsCommand(message, tokens = []) {
// Defaulting to status makes the bare lights command safe to type while
// still exposing explicit mutating forms under the configured prefix. This
// matters when several bot instances share a Discord server and each one
// needs its own command namespace.
const action = String(tokens.shift() || 'status').trim().toLowerCase();
if (!homeAssistantService) {
await message.reply({
content: 'Room light controls are unavailable.',
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action === 'status') {
await message.reply({
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
if (action !== 'lock' && action !== 'unlock') {
await message.reply({
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
const locked = action === 'lock';
// The bot command intentionally calls the shared policy setter instead of
// issuing direct Home Assistant entity commands. That keeps all secondary
// behavior centralized: web UI controls become disabled through the
// session lightPolicy update, lock-on still forces configured lights to
// white where possible, and commandService sees the same update event that
// forces rover lasers off while the room is locked on.
await homeAssistantService.setLightsLockedOn(locked, {
source: `bot-command:lights:${action}`,
forceApply: true,
});
await message.reply({
content: sanitizeMentions(locked ? 'Room lights locked on.' : 'Room lights unlocked.'),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
};
}
module.exports = { createLightsCommand };
@@ -3,6 +3,7 @@
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
const { AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io');
const { hostReplay } = require('../../replayMediaService');
const {
DEFAULT_ALLOWED_MENTIONS,
buildReplayJobId,
@@ -11,13 +12,13 @@ const {
createReplaySourceResolver,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
buildReplayFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('../replayWorkflow');
} = require('../../replayDeliveryService/workflow');
function createReplayCommand({
logger,
@@ -32,6 +33,7 @@ function createReplayCommand({
getActiveDrivers,
getNickname,
rovers,
discordConfig,
}) {
const sourceResolver = createReplaySourceResolver({
rovers,
@@ -80,25 +82,30 @@ function createReplayCommand({
});
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
let builtReplay = null;
let deliveredMedia = null;
try {
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) {
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,
title: job.title,
requester: job.requester,
includeSidebar: job.includeSidebar,
});
const { buffer, usedSources = job.sources, missingSources = [] } = builtReplay;
jobStatus.emit(job, 'uploading', { message: buildStatusMessage(job, 'uploading') });
if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
// Keep direct Discord commands consistent with web-triggered uploads and
// with the local hosting fallback used when Discord delivery is unavailable.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
const body = replayCaption.build({ job, usedSources, missingSources });
const uploadMessage = await progressMessage.reply({
content: body,
@@ -108,14 +115,36 @@ function createReplayCommand({
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');
deliveredMedia = buildDiscordReplayMediaPayload({ message: uploadMessage, attachment: uploadedAttachment, job });
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) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'ready')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
}
} 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);
jobStatus.emit(job, 'failed', { message: userMessage });
if (progressMessage?.edit) {
@@ -3,7 +3,7 @@
// Scope: Builds and sends rover status embed for one rover or all visible rovers.
const { EmbedBuilder } = require('discord.js');
const { buildBatteryStatusEmbed } = require('../batteryEmbeds');
const { resolveRoverSelector } = require('./resolvers');
const { resolveRoverSelector } = require('../../operatorCommandService/commands/resolvers');
function createStatusCommand({ rovers, roverManager }) {
return async function handleStatusCommand(message, roverId) {
@@ -0,0 +1,149 @@
// Discord Fleet Daily Reports
// Purpose: Schedules and delivers completed-day fleet summaries to the existing admin alert channel.
// Scope: Discord owns timing/formatting/delivery; the fleet service owns evidence, analysis, and durable delivery state.
const { DateTime } = require('luxon');
const { EmbedBuilder } = require('discord.js');
function parseSendTime(value) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim());
if (!match) return { hour: 8, minute: 0 };
return {
hour: Math.max(0, Math.min(23, Number(match[1]))),
minute: Math.max(0, Math.min(59, Number(match[2]))),
};
}
function nextRunAt({ zone, hour, minute }) {
const now = DateTime.now().setZone(zone);
let next = now.set({ hour, minute, second: 0, millisecond: 0 });
if (next <= now) next = next.plus({ days: 1 });
return next;
}
function formatNumber(value, digits = 1) {
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: digits });
}
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
let timer = null;
const reportConfig = fleetConfig?.discord || {};
const enabled = fleetReportService?.enabled && reportConfig.enabled !== false;
const channelId = discordConfig?.channels?.adminAlerts;
const zone = String(reportConfig.timezone || 'America/New_York');
const { hour, minute } = parseSendTime(reportConfig.sendAt);
function publicRoverIds() {
// Discord's shared admin-alert channel does not provide a per-viewer socket
// against which private-rover grants can be checked. Excluding private
// rovers here preserves the existing privacy boundary instead of assuming
// every channel reader has every private grant.
return roverManager.getRoster()
.filter((rover) => !rover?.private?.enabled)
.map((rover) => String(rover.id));
}
function completedDayRange() {
const end = DateTime.now().setZone(zone).startOf('day');
const start = end.minus({ days: 1 });
return {
reportDate: start.toISODate(),
since: start.toMillis(),
until: end.toMillis(),
};
}
function buildEmbed(reportDate, report) {
const totals = report.totals;
const attention = report.attention
.filter((item) => item.severity !== 'notice')
.slice(0, 12)
.map((item) => `${item.roverId}: ${item.title}`)
.join('\n') || 'No material battery or efficiency changes need attention.';
const roverLines = report.rovers.map((rover) => {
const health = rover.batteryHealth || {};
const efficiency = rover.overallWhPerKm == null
? `efficiency pending (${formatNumber(rover.distanceMm / 1000, 0)} m)`
: `${formatNumber(rover.overallWhPerKm)} Wh/km`;
const capacity = health.measuredUsableMah == null
? `health collecting (${health.confidence || 'low'} confidence)`
: `${formatNumber(health.measuredUsableMah / 1000, 2)} Ah usable · ${formatNumber(health.capacityRetentionPercent)}% retained · ${health.confidence} confidence`;
return `${rover.name}: ${formatNumber(rover.distanceMm / 1e6, 2)} km · ${formatNumber(rover.dischargedWh, 2)} Wh · ${efficiency}\n Battery: ${capacity}`;
}
).join('\n') || 'No public rover telemetry.';
return new EmbedBuilder()
.setTitle(`Daily fleet report — ${reportDate}`)
.setColor(totals.attentionCount ? 0xf0b651 : 0x4caf50)
.addFields(
{
name: 'Fleet energy',
value: `${formatNumber(totals.distanceMm / 1e6, 2)} km · ${formatNumber(totals.dischargedWh, 2)} Wh · ${totals.overallWhPerKm == null ? 'efficiency pending' : `${formatNumber(totals.overallWhPerKm)} Wh/km`} · ${formatNumber(totals.stationaryDischargedWh, 2)} stationary Wh`,
},
{ name: 'Needs attention', value: attention.slice(0, 1024) },
{ name: 'Rovers', value: roverLines.slice(0, 1024) },
)
.setFooter({ text: 'The server reports page contains the complete all-rovers metric table.' });
}
async function deliverPreviousDay() {
if (!enabled || !channelId) return;
const range = completedDayRange();
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
if (existing?.discordDeliveredAt) return;
const report = fleetReportService.getDailyReport({
since: range.since,
until: range.until,
roverIds: publicRoverIds(),
});
if (!report) return;
/*
Daily storage retains the exact metric report used for delivery, but the
Discord message intentionally has no raw JSON attachment. Admins need
actionable fleet comparisons here; the complete read-only evidence stays
on the reports page without turning routine events into notification noise.
*/
fleetReportService.storage.saveDailyReport(range.reportDate, report);
const sent = await sendToChannel(
channelId,
`Daily fleet report for ${range.reportDate}`,
{ embeds: [buildEmbed(range.reportDate, report)] },
{ parse: [] },
);
if (sent) {
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { deliveredAt: Date.now(), error: null });
} else {
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { error: 'Discord delivery returned no message' });
}
}
function scheduleNext() {
if (!enabled || !channelId) return;
const next = nextRunAt({ zone, hour, minute });
const delay = Math.max(1000, next.toMillis() - Date.now());
timer = setTimeout(async () => {
try {
await deliverPreviousDay();
} catch (err) {
logger.warn('Daily fleet report delivery failed', { error: err.message });
} finally {
scheduleNext();
}
}, delay);
timer.unref?.();
logger.info('Scheduled daily fleet report', { nextRunAt: next.toISO(), channelId });
}
function start() {
scheduleNext();
}
function stop() {
if (timer) clearTimeout(timer);
timer = null;
}
return { start, stop, deliverPreviousDay };
}
module.exports = {
createFleetDailyReports,
};
+130 -22
View File
@@ -5,10 +5,13 @@ const {
Client,
GatewayIntentBits,
Partials,
AttachmentBuilder,
} = require('discord.js');
const logger = require('../../globals/logger').child('discordBot');
const io = require('../../globals/io');
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const { parseCommandText } = require('../operatorCommandService/config');
const roverManager = require('../roverManager');
const { getRoster, lockRover, rovers } = roverManager;
const { MODES, getMode, setMode } = require('../modeManager');
@@ -20,6 +23,8 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const {
getGuildConfig,
listGuildConfigs,
@@ -36,9 +41,18 @@ const {
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
} = require('../verificationService');
const {
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
} = require('../identityService');
const {
attachDmMessage: attachPrivateAccessDmMessage,
getRequestByMessageId: getPrivateAccessRequestByMessageId,
@@ -48,26 +62,35 @@ const {
const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('./commands');
const { createCommandHandlers } = require('../operatorCommandService');
const greenModeService = require('../greenModeService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports');
const fleetReportService = require('../fleetReportService');
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayCaptionBuilder,
startDiscordTypingLoop,
buildReplayFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
} = require('../replayDeliveryService/workflow');
const config = loadConfig();
const discordConfig = config.discord || {};
const enabled = Boolean(discordConfig.token);
const enabled = isFeatureEnabled('discord');
// These normalized command names mirror the command router. Bridge-channel
// command replies are mirrored into web chat, so this entrypoint needs to know
// 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 lockdownAdminIds = new Set((config.admins || []).filter((admin) => admin.lockdown).map((admin) => String(admin.discord_id || '').trim()).filter(Boolean));
if (!enabled) {
logger.info('Discord bot disabled; missing token in config.discord.token');
logger.info('Discord feature disabled or missing required token');
return;
}
@@ -123,7 +146,74 @@ const presence = createPresenceManager({
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 });
}
// Every delivery path uses the job creation time, so a Discord upload
// and a server-hosted fallback always expose the same replay filename.
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
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,
client,
io,
@@ -151,6 +241,10 @@ const commands = createCommandHandlers({
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
getGuildConfig,
setGuildConfig,
removeGuildConfig,
@@ -159,15 +253,24 @@ const commands = createCommandHandlers({
listVerifiedUsers,
removeVerifiedUser,
listDeterredUsers,
listMutedUsers,
deterUser,
undeterUser,
muteUser,
unmuteUser,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
sendToChannel: channelIO.sendToChannel,
isAdminUser,
isLockdownAdminUser,
discordConfig,
config,
});
};
commandDependencies.transportHandlers = createDiscordTransportHandlers(commandDependencies);
const commands = createCommandHandlers(commandDependencies);
const integrations = createIntegrations({
logger,
@@ -209,16 +312,9 @@ const commands = createCommandHandlers({
const integrationHandlers = integrations.register();
function isTextCommand(content) {
const clean = String(content || '').trim();
const lower = clean.toLowerCase();
if (normalizedTimeStatusCommand && lower === normalizedTimeStatusCommand) return true;
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);
// Both transports share this parser so command detection cannot drift from
// the dispatcher when an installation changes its prefix.
return parseCommandText(content, config).matched;
}
function isBridgeChannelMessage(message) {
@@ -263,7 +359,8 @@ function createBridgeMirroredCommandMessage(message) {
client.on('messageCreate', async (message) => {
try {
await integrationHandlers.handleBridgeInbound(message);
await commands.handleCommand(createBridgeMirroredCommandMessage(message));
const commandMessage = createBridgeMirroredCommandMessage(message);
await commands.handleCommand(createDiscordCommandRequest(commandMessage, { isAdminUser, isLockdownAdminUser }));
} catch (err) {
logger.warn('Error handling Discord message', err.message);
}
@@ -272,6 +369,17 @@ client.on('messageCreate', async (message) => {
client.once('ready', () => {
logger.info('Discord bot logged in', { tag: client.user?.tag });
presence.schedulePresenceRotation();
// Discord is only a delivery consumer. Starting its scheduler after the bot
// is ready avoids failed sends during login while the collector continues to
// operate independently of Discord availability.
createFleetDailyReports({
logger,
discordConfig,
fleetConfig: config.fleetReports || {},
fleetReportService,
roverManager,
sendToChannel: channelIO.sendToChannel,
}).start();
});
client.login(discordConfig.token).catch((err) => {
@@ -2,28 +2,12 @@
// Purpose: Handles event-bus announcements to Discord channels.
// Scope: Processes supported event types and posts formatted messages/embeds.
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io');
const { buildBatteryStatusEmbed, buildBatteryCaption } = require('../batteryEmbeds');
const {
DEFAULT_ALLOWED_MENTIONS,
createReplayJob,
createJobStatusEmitter,
createReplayCaptionBuilder,
startDiscordTypingLoop,
sanitizeReplayTitleForFilename,
firstAttachmentFromMessage,
buildDiscordReplayMediaPayload,
buildAcceptedMessage,
buildStatusMessage,
normalizeUserError,
} = require('../replayWorkflow');
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']);
let skippedFirstModeAnnouncement = false;
const jobStatus = createJobStatusEmitter({ io, logger, sanitizeMentions });
const replayCaption = createReplayCaptionBuilder({ io, rovers, getActiveDrivers, getNickname, sanitizeMentions });
function buildEmbed({ title, description, color, includeSiteUrl = true }) {
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);
}
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) {
const { type, payload } = event || {};
const channels = discordConfig.channels || {};
@@ -200,9 +124,9 @@ function createBusEventHandler(deps) {
});
break;
}
case 'replay.requested':
handleReplayRequested(event);
break;
// Replay requests are deliberately consumed by replayDeliveryService.
// Discord registers only a preferred delivery provider, allowing the
// same request to fall back locally without a second event subscriber.
case 'buttonBox.discordStalkerPing': {
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
announce({
@@ -234,7 +158,7 @@ function createBusEventHandler(deps) {
}
}
return { handleBusEvent, handleReplayRequested };
return { handleBusEvent };
}
module.exports = { createBusEventHandler };
+19 -2
View File
@@ -2,9 +2,16 @@
// Purpose: Defines the embed Http Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService');
const { renderIndexHtml, renderOgImage, renderWebManifest } = 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. Keeping this list aligned with webui/src/main.jsx lets direct
loads and browser refreshes receive the same rendered index document as
in-app navigation. The retired desktop composition is intentionally exposed
at /old; the removed /newdrive route is intentionally absent.
*/
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);
@@ -22,3 +29,13 @@ app.get('/og/preview.png', async (req, res) => {
res.status(500).send('Failed to render embed image');
}
});
app.get('/manifest.webmanifest', (req, res) => {
/*
The manifest varies with server configuration, so it is served by the
application rather than copied into Vite's static output. Revalidation
lets browsers pick up branding changes after the server is restarted.
*/
res.set('Cache-Control', 'no-cache');
res.type('application/manifest+json').send(renderWebManifest());
});
+142 -47
View File
@@ -2,26 +2,60 @@
// Purpose: Defines the embed Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const path = require('path');
const fs = require('fs');
const fsp = require('fs/promises');
const sharp = require('sharp');
const logger = require('../../globals/logger').child('embedService');
const { getMode } = require('../modeManager');
const roverManager = require('../roverManager');
const { getActiveDrivers, getTurnQueues } = require('../turnService');
const { getRoomCameras } = require('../roomCameraService');
const { getRoomCameraState } = require('../roomCameraService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { resolveSiteMetadata } = require('../../helpers/siteMetadata');
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html');
const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png');
const ANALYTICS_HTML_PATH = resolveDataPath('analytics.html');
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->';
const SITE_METADATA_PLACEHOLDER = '<!-- site-metadata:inject -->';
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;
const BASE_BG = { r: 8, g: 12, b: 22 };
let cachedIndexHtml = null;
let cachedIndexMtimeMs = 0;
/*
Analytics provider markup belongs to the server operator, not to the shared
web build. Loading the snippet once at process startup makes deployment
behavior predictable: replacing analytics.html takes effect on the next
normal server restart, and no analytics configuration needs to travel over
Socket.IO or be exposed through a JSON endpoint.
This file is intentionally trusted as raw HTML. Anyone able to write files in
the server data directory already controls the deployment, and allowing a
complete head snippet is what keeps this integration compatible with Umami,
Plausible, Matomo, or a custom provider without provider-specific server code.
*/
function loadAnalyticsHeadHtml() {
if (!fs.existsSync(ANALYTICS_HTML_PATH)) return '';
try {
return fs.readFileSync(ANALYTICS_HTML_PATH, 'utf8').trim();
} catch (err) {
/*
Analytics is observability-only, so a permissions or read error must not
prevent operators and drivers from loading the rover controls.
*/
logger.warn('Unable to read analytics head HTML; continuing without analytics', err.message);
return '';
}
}
const analyticsHeadHtml = loadAnalyticsHeadHtml();
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
@@ -52,6 +86,20 @@ function getBaseUrl(req) {
return `${proto}://${host}`;
}
function getPagePath(req) {
/*
Canonical URLs should describe the page rather than a tracking/query
variant of it. Express's path value excludes the query string and is safe
to combine with either the configured public URL or the current request.
*/
return req.path || '/';
}
function joinPublicUrl(baseUrl, pagePath) {
const normalizedPath = pagePath.startsWith('/') ? pagePath : `/${pagePath}`;
return `${baseUrl}${normalizedPath}`;
}
function getPrimaryRoomCamera() {
const cameras = getRoomCameras();
if (!cameras.length) return null;
@@ -86,33 +134,6 @@ function buildEmbedCopy(state, camera) {
lockdown: 'locked',
}[mode] || mode;
let title = 'Roomba Rover';
if (mode === 'lockdown') {
title = 'Private mode is on';
} else if (roversOnline === 0) {
title = 'Rovers offline - check back soon';
} else if (driverCount > 0) {
title = 'Rovers in use - drive a rover';
} else if (mode === 'turns') {
title = 'Controls open - jump in';
} else {
title = 'Controls open - drive a rover';
}
const descriptionParts = [];
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
if (driverCount > 0) {
descriptionParts.push(`${driverCount} driving`);
} else {
descriptionParts.push('no active drivers');
}
if (mode === 'lockdown') {
descriptionParts.push('privacy mode');
} else {
descriptionParts.push(modeLabel);
}
const description = descriptionParts.join(' | ');
const statsParts = [
`${roversOnline} online`,
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
@@ -125,20 +146,17 @@ function buildEmbedCopy(state, camera) {
const cameraLabel = camera?.name || camera?.id || 'room cam';
return {
title,
description,
subtitle: 'Control a live rover from your browser',
stats: statsParts.join(' | '),
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
};
}
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
function buildMetaTags({ title, description, imageUrl, pageUrl, canonicalUrl }) {
const safeTitle = escapeHtml(title);
const safeDescription = escapeHtml(description);
const safeImage = escapeHtml(imageUrl);
const safeUrl = escapeHtml(pageUrl);
return [
const tags = [
'<!-- embed meta -->',
`<meta name="description" content="${safeDescription}" />`,
`<meta property="og:title" content="${safeTitle}" />`,
@@ -153,7 +171,27 @@ function buildMetaTags({ title, description, imageUrl, pageUrl }) {
`<meta name="twitter:title" content="${safeTitle}" />`,
`<meta name="twitter:description" content="${safeDescription}" />`,
`<meta name="twitter:image" content="${safeImage}" />`,
'<!-- /embed meta -->',
];
/*
Only advertise a canonical address when the operator supplied a valid
public URL. Guessing from request headers would permanently identify a LAN
hostname or reverse-proxy hop as the public home of the instance.
*/
if (canonicalUrl) {
tags.push(`<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />`);
}
tags.push('<!-- /embed meta -->');
return tags.join('\n ');
}
function buildSiteMetadataTags(siteMetadata) {
return [
'<!-- site metadata -->',
`<meta name="theme-color" content="${escapeHtml(siteMetadata.accentColor)}" />`,
`<meta name="apple-mobile-web-app-title" content="${escapeHtml(siteMetadata.shortName)}" />`,
`<title>${escapeHtml(siteMetadata.name)}</title>`,
'<!-- /site metadata -->',
].join('\n ');
}
@@ -165,23 +203,47 @@ async function renderIndexHtml(req) {
activeDrivers: getActiveDrivers(),
turnQueues: getTurnQueues(),
};
const config = loadConfig();
const pageTitle = config?.site?.title || 'Roomba Rover';
const siteMetadata = resolveSiteMetadata();
const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera);
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
const pagePath = getPagePath(req);
const canonicalUrl = siteMetadata.publicUrl
? joinPublicUrl(siteMetadata.publicUrl, pagePath)
: null;
const pageUrl = canonicalUrl || joinPublicUrl(baseUrl, pagePath);
const metaBlock = buildMetaTags({
title: pageTitle,
description: copy.description,
title: siteMetadata.name,
description: siteMetadata.description,
imageUrl,
pageUrl,
canonicalUrl,
});
const siteMetadataBlock = buildSiteMetadataTags(siteMetadata);
let html = await loadIndexHtml();
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
/*
Prefer the explicit marker so the insertion point remains stable across
Vite output changes. The closing-head fallback also keeps deployed builds
made before the marker was introduced compatible with the runtime loader.
*/
if (html.includes(ANALYTICS_PLACEHOLDER)) {
html = html.replace(ANALYTICS_PLACEHOLDER, analyticsHeadHtml);
} else if (analyticsHeadHtml) {
html = html.replace('</head>', ` ${analyticsHeadHtml}\n </head>`);
}
/*
Keeping all instance-specific head values behind one marker prevents the
built index from carrying a second set of hardcoded titles and colors.
The fallback supports an older built index during a rolling deployment.
*/
if (html.includes(SITE_METADATA_PLACEHOLDER)) {
html = html.replace(SITE_METADATA_PLACEHOLDER, siteMetadataBlock);
} else {
html = html.replace('</head>', ` ${siteMetadataBlock}\n </head>`);
}
if (html.includes('<!-- embed meta -->')) {
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
} else {
@@ -190,7 +252,7 @@ async function renderIndexHtml(req) {
return html;
}
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame, accentColor, accentTextColor }) {
const titleSize = 64;
const subtitleSize = 34;
const statsSize = 30;
@@ -207,8 +269,8 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
</defs>
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
<rect x="58" y="50" width="206" height="36" rx="18" fill="${accentColor}" />
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="${accentTextColor}">
${escapeXml(badgeText)}
</text>
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
@@ -235,6 +297,7 @@ async function renderOgImage() {
};
const camera = getPrimaryRoomCamera();
const copy = buildEmbedCopy(state, camera);
const siteMetadata = resolveSiteMetadata();
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
const frame = cameraState?.frame || null;
const hasFrame = Boolean(frame);
@@ -246,17 +309,20 @@ async function renderOgImage() {
width: OG_WIDTH,
height: OG_HEIGHT,
channels: 3,
background: BASE_BG,
background: siteMetadata.backgroundColor,
},
});
const overlaySvg = Buffer.from(
buildOverlaySvg({
title: copy.title,
subtitle: copy.subtitle,
title: siteMetadata.name,
// The image must use the same resolved description as the page metadata and installed shortcut.
subtitle: siteMetadata.description,
stats: copy.stats,
cameraLabel: copy.cameraLabel,
hasFrame,
accentColor: siteMetadata.accentColor,
accentTextColor: siteMetadata.accentTextColor,
}),
);
@@ -272,7 +338,36 @@ async function renderOgImage() {
return base.composite(composite).png().toBuffer();
}
function renderWebManifest() {
const siteMetadata = resolveSiteMetadata();
/*
The manifest is generated from the same resolved values as the HTML and
social image, so browser tabs, installed shortcuts, and launch screens do
not drift into three separately configured identities.
*/
return JSON.stringify({
name: siteMetadata.name,
short_name: siteMetadata.shortName,
description: siteMetadata.description,
start_url: '/',
scope: '/',
display: 'standalone',
background_color: siteMetadata.backgroundColor,
theme_color: siteMetadata.accentColor,
icons: [
{
src: '/bitmap.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
],
});
}
module.exports = {
renderIndexHtml,
renderOgImage,
renderWebManifest,
};
@@ -0,0 +1,629 @@
// Fleet Report Collector
// Purpose: Converts existing server events and high-rate rover sensor frames into bounded historical evidence.
// Scope: Performs passive normalization, battery-current integration, minute aggregation, and battery-session classification.
const crypto = require('crypto');
const MINUTE_MS = 60 * 1000;
const FULL_WAIT_MS = 5 * 60 * 1000;
const SESSION_KIND_CONFIRM_SAMPLES = 3;
function finite(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function minimum(previous, value) {
if (value == null) return previous;
return previous == null ? value : Math.min(previous, value);
}
function maximum(previous, value) {
if (value == null) return previous;
return previous == null ? value : Math.max(previous, value);
}
function eventRoverId(event) {
const payload = event?.payload || {};
// Generic payload `id` fields are commonly message, request, or job IDs and
// must not be mistaken for rover identities. Producers use roverId (or an
// explicit rover object) whenever the existing privacy resolver should scope
// an event to a physical rover.
return payload.roverId || payload.rover?.id || null;
}
function inferVisibility(event) {
const payload = event?.payload || {};
// Producers that know a stricter visibility scope may attach it explicitly.
// Otherwise rover-scoped events are filtered later against the same visible
// roster that drives the live UI, while verification/auth details retain the
// existing lockdown-only boundary.
if (payload.visibility) return String(payload.visibility);
if (event?.source === 'verification' || event?.source === 'identity' || event?.source === 'auth') {
return 'lockdown';
}
return eventRoverId(event) ? 'rover' : 'global';
}
function inferSeverity(type = '') {
const value = String(type).toLowerCase();
if (/fault|critical|urgent|failed|failure/.test(value)) return 'critical';
if (/warn|offline|rejected|stopped|removed|denied/.test(value)) return 'warning';
if (/started|completed|online|resolved|updated/.test(value)) return 'notice';
return 'informational';
}
function batteryKey(roverId) {
// Until an admin registers a physical battery, the stable fallback keeps all
// observations attached to the rover without pretending the hardware has a
// serial number exposed by OI.
return `unregistered:${roverId}`;
}
function makeMinute(roverId, now) {
return {
roverId,
bucketTs: Math.floor(now / MINUTE_MS) * MINUTE_MS,
sampleCount: 0,
coverageMs: 0,
gapCount: 0,
chargedMah: 0,
dischargedMah: 0,
chargedWh: 0,
dischargedWh: 0,
movingDischargedWh: 0,
stationaryDischargedWh: 0,
movingMs: 0,
maximumSpeedMmPerSecond: null,
minVoltageMv: null,
maxVoltageMv: null,
voltageTotal: 0,
voltageCount: 0,
minCurrentMa: null,
maxCurrentMa: null,
currentTotal: 0,
currentCount: 0,
minTemperatureC: null,
maxTemperatureC: null,
temperatureTotal: 0,
temperatureCount: 0,
minChargeMah: null,
maxChargeMah: null,
lastChargeMah: null,
reportedCapacityMah: null,
dockedSamples: 0,
chargingSamples: 0,
commandCount: 0,
driveCommandCount: 0,
rejectedCommandCount: 0,
distanceMm: 0,
bumpCount: 0,
cliffCount: 0,
wheelDropCount: 0,
virtualWallCount: 0,
overcurrentEpisodeCount: 0,
};
}
function persistedMinute(minute) {
return {
roverId: minute.roverId,
bucketTs: minute.bucketTs,
sampleCount: minute.sampleCount,
coverageMs: Math.round(minute.coverageMs),
gapCount: minute.gapCount,
chargedMah: minute.chargedMah,
dischargedMah: minute.dischargedMah,
chargedWh: minute.chargedWh,
dischargedWh: minute.dischargedWh,
movingDischargedWh: minute.movingDischargedWh,
stationaryDischargedWh: minute.stationaryDischargedWh,
movingMs: Math.round(minute.movingMs),
maximumSpeedMmPerSecond: minute.maximumSpeedMmPerSecond,
minVoltageMv: minute.minVoltageMv,
maxVoltageMv: minute.maxVoltageMv,
avgVoltageMv: minute.voltageCount ? minute.voltageTotal / minute.voltageCount : null,
minCurrentMa: minute.minCurrentMa,
maxCurrentMa: minute.maxCurrentMa,
avgCurrentMa: minute.currentCount ? minute.currentTotal / minute.currentCount : null,
minTemperatureC: minute.minTemperatureC,
maxTemperatureC: minute.maxTemperatureC,
avgTemperatureC: minute.temperatureCount ? minute.temperatureTotal / minute.temperatureCount : null,
minChargeMah: minute.minChargeMah,
maxChargeMah: minute.maxChargeMah,
lastChargeMah: minute.lastChargeMah,
reportedCapacityMah: minute.reportedCapacityMah,
dockedSamples: minute.dockedSamples,
chargingSamples: minute.chargingSamples,
commandCount: minute.commandCount,
driveCommandCount: minute.driveCommandCount,
rejectedCommandCount: minute.rejectedCommandCount,
distanceMm: minute.distanceMm,
bumpCount: minute.bumpCount,
cliffCount: minute.cliffCount,
wheelDropCount: minute.wheelDropCount,
virtualWallCount: minute.virtualWallCount,
overcurrentEpisodeCount: minute.overcurrentEpisodeCount,
};
}
function newBatterySession(roverId, kind, now, sensors, state) {
return {
roverId,
batteryKey: state.batteryKey || batteryKey(roverId),
kind,
startedAt: now,
endedAt: null,
startChargeMah: finite(sensors?.batteryChargeMah),
endChargeMah: null,
chargedMah: 0,
dischargedMah: 0,
minVoltageMv: finite(sensors?.voltageMv),
maxVoltageMv: finite(sensors?.voltageMv),
minTemperatureC: finite(sensors?.batteryTemperatureC),
maxTemperatureC: finite(sensors?.batteryTemperatureC),
sampleCount: 0,
gapCount: 0,
status: 'open',
confidence: 'low',
qualificationReason: 'session is still open',
details: {
startedFromQualifiedFull: Boolean(state.fullQualifiedAt),
fullQualifiedAt: state.fullQualifiedAt,
warnMah: finite(state.lastBatteryState?.warn),
urgentMah: finite(state.lastBatteryState?.urgent),
configuredFullMah: finite(state.lastBatteryState?.full),
},
};
}
function observedSessionKind(sensors) {
const code = finite(sensors?.chargingState?.code);
const docked = Boolean(sensors?.chargingSources?.homeBase || sensors?.chargingSources?.internalCharger);
const current = finite(sensors?.currentMa);
if (docked && (code === 1 || code === 2 || code === 3 || (current != null && current > 25))) return 'charging';
if (!docked && current != null && current < -25) return 'discharging';
return 'idle';
}
function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapacityTestDepthPercent }) {
const roverStates = new Map();
const lastManagerSampleAt = new Map();
const diagnostics = {
startedAt: Date.now(),
eventsObserved: 0,
eventsStored: 0,
sensorFramesObserved: 0,
validBatteryFrames: 0,
integrationGaps: 0,
minuteWrites: 0,
sessionsCompleted: 0,
lastEventAt: null,
lastSensorAt: null,
lastError: null,
};
function stateFor(roverId, now) {
if (!roverStates.has(roverId)) {
roverStates.set(roverId, {
roverId,
lastAt: null,
minute: makeMinute(roverId, now),
candidateKind: null,
candidateCount: 0,
sessionKind: 'idle',
session: null,
waitingSince: null,
fullQualifiedAt: null,
lastBatteryState: null,
batteryKey: storage.getActiveBattery?.(roverId)?.batteryKey || batteryKey(roverId),
lastOdometerTotalMm: null,
safety: {
bump: false,
cliff: false,
wheelDrop: false,
virtualWall: false,
overcurrent: false,
overcurrentStartedAt: null,
overcurrentSamples: 0,
},
});
}
return roverStates.get(roverId);
}
function collectEvent(event = {}) {
diagnostics.eventsObserved += 1;
diagnostics.lastEventAt = Date.now();
try {
const normalized = {
ts: finite(event.ts) || Date.now(),
source: String(event.source || 'unknown'),
type: String(event.type || 'unknown'),
roverId: eventRoverId(event),
visibility: inferVisibility(event),
severity: inferSeverity(event.type),
correlationId: event?.payload?.correlationId || event?.payload?.jobId || event?.payload?.sessionId || null,
payload: event.payload ?? null,
};
if (storage.insertEvent(normalized)) diagnostics.eventsStored += 1;
} catch (err) {
diagnostics.lastError = err.message;
logger.warn('Fleet collector ignored malformed domain event', { error: err.message });
}
}
function updateMinute(minute, sensors, elapsedMs, chargedMah, dischargedMah, chargedWh, dischargedWh, gap) {
const voltage = finite(sensors?.voltageMv);
const current = finite(sensors?.currentMa);
const temperature = finite(sensors?.batteryTemperatureC);
const charge = finite(sensors?.batteryChargeMah);
const capacity = finite(sensors?.batteryCapacityMah);
minute.sampleCount += 1;
minute.coverageMs += elapsedMs;
minute.gapCount += gap ? 1 : 0;
minute.chargedMah += chargedMah;
minute.dischargedMah += dischargedMah;
minute.chargedWh += chargedWh;
minute.dischargedWh += dischargedWh;
/*
Movement classification deliberately consumes the center speed produced
by odometerService. That service already owns encoder rollover, physical
conversion, and impossible-jump rejection; duplicating those rules here
would allow reporting and the rover's actual odometer to disagree.
*/
const speed = finite(sensors?.wheelSpeedsMmPerSecond?.center);
const moving = speed != null && Math.abs(speed) >= 1;
if (moving) {
minute.movingMs += elapsedMs;
minute.movingDischargedWh += dischargedWh;
} else {
minute.stationaryDischargedWh += dischargedWh;
}
minute.maximumSpeedMmPerSecond = maximum(minute.maximumSpeedMmPerSecond, speed == null ? null : Math.abs(speed));
minute.minVoltageMv = minimum(minute.minVoltageMv, voltage);
minute.maxVoltageMv = maximum(minute.maxVoltageMv, voltage);
if (voltage != null) { minute.voltageTotal += voltage; minute.voltageCount += 1; }
minute.minCurrentMa = minimum(minute.minCurrentMa, current);
minute.maxCurrentMa = maximum(minute.maxCurrentMa, current);
if (current != null) { minute.currentTotal += current; minute.currentCount += 1; }
minute.minTemperatureC = minimum(minute.minTemperatureC, temperature);
minute.maxTemperatureC = maximum(minute.maxTemperatureC, temperature);
if (temperature != null) { minute.temperatureTotal += temperature; minute.temperatureCount += 1; }
minute.minChargeMah = minimum(minute.minChargeMah, charge);
minute.maxChargeMah = maximum(minute.maxChargeMah, charge);
minute.lastChargeMah = charge;
minute.reportedCapacityMah = capacity;
if (sensors?.chargingSources?.homeBase) minute.dockedSamples += 1;
if (observedSessionKind(sensors) === 'charging') minute.chargingSamples += 1;
}
function finishSession(state, now, sensors, reason) {
const session = state.session;
if (!session) return;
session.endedAt = now;
session.endChargeMah = finite(sensors?.batteryChargeMah);
session.status = 'completed';
if (session.kind === 'discharging') {
const configuredFull = finite(session.details.configuredFullMah);
const startCharge = finite(session.startChargeMah);
const endCharge = finite(session.endChargeMah);
const reference = configuredFull || startCharge;
const observedDepth = reference && startCharge != null && endCharge != null
? Math.max(0, ((startCharge - endCharge) / reference) * 100)
: 0;
const reachedLowEndpoint = Boolean(
state.lastBatteryState?.urgentActive ||
(finite(session.details.urgentMah) != null && endCharge != null && endCharge <= session.details.urgentMah),
);
const qualified = Boolean(
session.details.startedFromQualifiedFull &&
reachedLowEndpoint &&
observedDepth >= minimumCapacityTestDepthPercent &&
session.gapCount === 0,
);
session.details.observedDepthPercent = observedDepth;
session.details.reachedLowEndpoint = reachedLowEndpoint;
session.details.capacityTestQualified = qualified;
if (qualified) {
session.confidence = 'high';
session.qualificationReason = 'continuous qualified-full to low-endpoint discharge';
} else if (observedDepth >= 30 && session.gapCount <= 1) {
session.confidence = 'medium';
session.qualificationReason = reason || 'useful partial discharge; not a full capacity test';
} else {
session.confidence = 'low';
session.qualificationReason = reason || 'insufficient depth, endpoint, or telemetry coverage';
}
} else {
session.confidence = session.gapCount === 0 ? 'high' : session.gapCount <= 1 ? 'medium' : 'low';
session.qualificationReason = reason || 'charging session completed';
}
storage.insertBatterySession(session);
diagnostics.sessionsCompleted += 1;
state.session = null;
}
function applySessionKind(state, nextKind, now, sensors) {
if (nextKind === state.sessionKind) {
state.candidateKind = null;
state.candidateCount = 0;
return;
}
if (state.candidateKind !== nextKind) {
state.candidateKind = nextKind;
state.candidateCount = 1;
return;
}
state.candidateCount += 1;
if (state.candidateCount < SESSION_KIND_CONFIRM_SAMPLES) return;
finishSession(state, now, sensors, `state changed from ${state.sessionKind} to ${nextKind}`);
state.sessionKind = nextKind;
state.candidateKind = null;
state.candidateCount = 0;
if (nextKind === 'charging' || nextKind === 'discharging') {
state.session = newBatterySession(state.roverId, nextKind, now, sensors, state);
// A qualified-full marker is consumed by the next discharge. Leaving it
// set during the open session records the evidence in session details,
// while clearing it prevents later partial sessions from inheriting it.
if (nextKind === 'discharging') state.fullQualifiedAt = null;
}
}
function updateFullQualification(state, now, sensors) {
const waiting = finite(sensors?.chargingState?.code) === 4;
if (waiting) {
if (state.waitingSince == null) state.waitingSince = now;
if (now - state.waitingSince >= FULL_WAIT_MS && state.fullQualifiedAt == null) {
state.fullQualifiedAt = state.waitingSince + FULL_WAIT_MS;
}
} else {
state.waitingSince = null;
}
}
function collectSensor({ roverId, sensors, batteryState } = {}) {
diagnostics.sensorFramesObserved += 1;
diagnostics.lastSensorAt = Date.now();
if (!roverId || !sensors || finite(sensors.currentMa) == null) return;
diagnostics.validBatteryFrames += 1;
const now = Date.now();
const state = stateFor(String(roverId), now);
state.lastBatteryState = batteryState || state.lastBatteryState;
const elapsedMs = state.lastAt == null ? 0 : Math.max(0, now - state.lastAt);
const gap = elapsedMs > maximumIntegrationGapMs;
const validElapsedMs = gap ? 0 : elapsedMs;
if (gap) diagnostics.integrationGaps += 1;
const currentMa = finite(sensors.currentMa) || 0;
const deltaMah = currentMa * validElapsedMs / 3600000;
const chargedMah = Math.max(0, deltaMah);
const dischargedMah = Math.max(0, -deltaMah);
const voltageMv = finite(sensors.voltageMv);
/*
Millivolts multiplied by milliamps are microwatts. Dividing their
millisecond product by 3.6e12 therefore yields watt-hours. Integrating
voltage and current together here is required: multiplying independent
daily averages later would produce incorrect energy whenever load varies.
*/
const deltaWh = voltageMv == null ? 0 : voltageMv * currentMa * validElapsedMs / 3.6e12;
const chargedWh = Math.max(0, deltaWh);
const dischargedWh = Math.max(0, -deltaWh);
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
if (state.minute.bucketTs !== bucketTs) {
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
state.minute = makeMinute(state.roverId, now);
}
updateMinute(
state.minute,
sensors,
validElapsedMs,
chargedMah,
dischargedMah,
chargedWh,
dischargedWh,
gap,
);
const bumps = sensors?.bumpsAndWheelDrops || {};
const nextSafety = {
bump: Boolean(bumps.bumpLeft || bumps.bumpRight),
cliff: Boolean(sensors.cliffLeft || sensors.cliffFrontLeft || sensors.cliffFrontRight || sensors.cliffRight),
wheelDrop: Boolean(bumps.wheelDropLeft || bumps.wheelDropRight),
virtualWall: Boolean(sensors.virtualWall),
overcurrent: Boolean(
sensors?.wheelOvercurrents?.leftWheel || sensors?.wheelOvercurrents?.rightWheel ||
sensors?.wheelOvercurrents?.mainBrush || sensors?.wheelOvercurrents?.sideBrush,
),
};
if (nextSafety.bump && !state.safety.bump) state.minute.bumpCount += 1;
if (nextSafety.cliff && !state.safety.cliff) state.minute.cliffCount += 1;
if (nextSafety.wheelDrop && !state.safety.wheelDrop) state.minute.wheelDropCount += 1;
if (nextSafety.virtualWall && !state.safety.virtualWall) state.minute.virtualWallCount += 1;
if (nextSafety.overcurrent) state.safety.overcurrentSamples += 1;
if (nextSafety.overcurrent && !state.safety.overcurrent) {
state.minute.overcurrentEpisodeCount += 1;
state.safety.overcurrentStartedAt = now;
state.safety.overcurrentSamples = 1;
collectEvent({
source: 'fleetReportService',
type: 'overcurrent.episode.started',
ts: now,
payload: { roverId: state.roverId, motors: sensors.wheelOvercurrents },
});
} else if (!nextSafety.overcurrent && state.safety.overcurrent) {
collectEvent({
source: 'fleetReportService',
type: 'overcurrent.episode.resolved',
ts: now,
payload: {
roverId: state.roverId,
startedAt: state.safety.overcurrentStartedAt,
durationMs: Math.max(0, now - (state.safety.overcurrentStartedAt || now)),
sampleCount: state.safety.overcurrentSamples,
},
});
state.safety.overcurrentStartedAt = null;
state.safety.overcurrentSamples = 0;
}
Object.assign(state.safety, nextSafety);
updateFullQualification(state, now, sensors);
applySessionKind(state, observedSessionKind(sensors), now, sensors);
if (state.session) {
const session = state.session;
session.sampleCount += 1;
session.gapCount += gap ? 1 : 0;
session.chargedMah += chargedMah;
session.dischargedMah += dischargedMah;
session.minVoltageMv = minimum(session.minVoltageMv, finite(sensors.voltageMv));
session.maxVoltageMv = maximum(session.maxVoltageMv, finite(sensors.voltageMv));
session.minTemperatureC = minimum(session.minTemperatureC, finite(sensors.batteryTemperatureC));
session.maxTemperatureC = maximum(session.maxTemperatureC, finite(sensors.batteryTemperatureC));
}
state.lastAt = now;
}
function collectCommand(command = {}) {
if (!command.roverId) return;
const now = finite(command.ts) || Date.now();
const state = stateFor(String(command.roverId), now);
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
if (state.minute.bucketTs !== bucketTs) {
if (state.minute.sampleCount || state.minute.commandCount) {
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
}
state.minute = makeMinute(state.roverId, now);
}
state.minute.commandCount += 1;
if (command.type === 'drive' || command.type === 'motors') state.minute.driveCommandCount += 1;
if (command.outcome === 'rejected') state.minute.rejectedCommandCount += 1;
// Drive/motor commands can arrive at control-loop frequency. Their exact
// volume belongs in minute counters, while rejections and low-frequency
// actions remain individually inspectable. This preserves operational
// depth without turning normal held movement into an event-timeline flood.
if ((command.type !== 'drive' && command.type !== 'motors') || command.outcome === 'rejected') {
collectEvent({
source: 'commandService',
type: `command.${command.outcome || 'observed'}`,
ts: now,
payload: command,
});
}
}
function collectManagerEvent(kind, event = {}) {
const roverId = event.roverId ? String(event.roverId) : null;
if (kind === 'hostStats') {
const key = `${kind}:${roverId || 'unknown'}`;
const now = Date.now();
// Host statistics arrive periodically and change gradually. One exact
// sample every five minutes retains long-term diagnostic evidence while
// avoiding a timeline row for every routine host heartbeat.
if (now - (lastManagerSampleAt.get(key) || 0) < 5 * 60 * 1000) return;
lastManagerSampleAt.set(key, now);
collectEvent({
source: 'roverHost',
type: 'host.sample',
ts: event.receivedAt || now,
payload: { roverId, stats: event.stats || null },
});
return;
}
const payload = { ...event };
// Live rover records contain websocket handles, sets, and other runtime
// objects. The lifecycle facts are sufficient evidence and serialize
// predictably without copying those control-owned objects into storage.
delete payload.record;
collectEvent({
source: 'roverManager',
type: `roverManager.${kind}`,
payload,
});
}
function collectOdometer({ roverId, odometer } = {}) {
if (!roverId || !odometer) return;
const now = finite(odometer.updatedAt) || Date.now();
const state = stateFor(String(roverId), now);
const totalMm = finite(odometer.totalMm);
if (totalMm == null) return;
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
if (state.minute.bucketTs !== bucketTs) {
if (state.minute.sampleCount || state.minute.commandCount || state.minute.distanceMm) {
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
}
state.minute = makeMinute(state.roverId, now);
}
if (state.lastOdometerTotalMm != null && totalMm >= state.lastOdometerTotalMm) {
// Odometer total is already rollover-corrected and sanity-filtered by its
// owning service. Only non-negative increments belong in this report;
// resets establish a new baseline instead of subtracting fleet distance.
state.minute.distanceMm += totalMm - state.lastOdometerTotalMm;
}
state.lastOdometerTotalMm = totalMm;
}
function flushMinutes() {
roverStates.forEach((state) => {
if (!state.minute.sampleCount && !state.minute.commandCount && !state.minute.distanceMm) return;
storage.upsertMinute(persistedMinute(state.minute));
diagnostics.minuteWrites += 1;
});
}
function getLiveState() {
return Array.from(roverStates.values()).map((state) => ({
roverId: state.roverId,
lastAt: state.lastAt,
sessionKind: state.sessionKind,
waitingSince: state.waitingSince,
fullQualifiedAt: state.fullQualifiedAt,
minute: persistedMinute(state.minute),
openSession: state.session ? {
...state.session,
// The database serializer is not involved in this live response, so a
// defensive copy prevents UI consumers from mutating collector state.
details: { ...state.session.details },
} : null,
}));
}
function getDiagnostics() {
return {
...diagnostics,
activeRovers: roverStates.size,
openSessions: Array.from(roverStates.values()).filter((state) => state.session).length,
instanceId: crypto.createHash('sha1').update(String(diagnostics.startedAt)).digest('hex').slice(0, 10),
};
}
function refreshBatteryIdentity(roverId) {
const id = String(roverId || '');
if (!id) return;
const state = roverStates.get(id);
if (!state) return;
state.batteryKey = storage.getActiveBattery?.(id)?.batteryKey || batteryKey(id);
}
return {
collectEvent,
collectSensor,
collectCommand,
collectManagerEvent,
collectOdometer,
flushMinutes,
getLiveState,
getDiagnostics,
refreshBatteryIdentity,
};
}
module.exports = {
createCollector,
};
@@ -0,0 +1,138 @@
// Fleet Report Collector Tests
// Purpose: Verifies signed-current integration, gap rejection, and high-rate command noise reduction independently of SQLite.
// Scope: Uses an in-memory storage double so tests exercise collection policy without touching development data files.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCollector } = require('./collector');
function makeHarness() {
const writes = { events: [], minutes: [], sessions: [] };
const storage = {
insertEvent(event) { writes.events.push(event); return { changes: 1 }; },
upsertMinute(minute) { writes.minutes.push({ ...minute }); return { changes: 1 }; },
insertBatterySession(session) { writes.sessions.push({ ...session }); return { changes: 1 }; },
};
const collector = createCollector({
storage,
logger: { warn() {} },
maximumIntegrationGapMs: 5000,
minimumCapacityTestDepthPercent: 60,
});
return { collector, writes };
}
function sensors(overrides = {}) {
return {
currentMa: -3600,
voltageMv: 14500,
batteryTemperatureC: 25,
batteryChargeMah: 2000,
batteryCapacityMah: 3000,
chargingState: { code: 0, label: 'not charging' },
chargingSources: { homeBase: false, internalCharger: false },
...overrides,
};
}
test('integrates signed battery current while excluding long telemetry gaps', () => {
const { collector } = makeHarness();
const originalNow = Date.now;
let now = 1_000_000;
Date.now = () => now;
try {
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
now += 1000;
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
now += 6000;
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
const live = collector.getLiveState()[0].minute;
// -3600 mA for one valid second is exactly one discharged mAh. The six
// second interval exceeds the configured integration gap and adds no
// fictional throughput.
assert.equal(live.dischargedMah, 1);
assert.equal(live.chargedMah, 0);
assert.equal(live.gapCount, 1);
assert.equal(live.coverageMs, 1000);
} finally {
Date.now = originalNow;
}
});
test('integrates watt-hours and classifies energy with existing odometer speed', () => {
const { collector } = makeHarness();
const originalNow = Date.now;
let now = 1_500_000;
Date.now = () => now;
try {
collector.collectSensor({
roverId: 'alpha',
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
});
now += 1000;
collector.collectSensor({
roverId: 'alpha',
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
});
now += 1000;
collector.collectSensor({
roverId: 'alpha',
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 0, right: 0, center: 0 } }),
});
const live = collector.getLiveState()[0].minute;
/*
A 14.5 V, 3.6 A discharge is 52.2 W. Two one-second intervals therefore
consume 52.2 / 1800 Wh; the first is moving and the second stationary.
This verifies that the collector uses odometer speed rather than deriving
movement from commands.
*/
assert.ok(Math.abs(live.dischargedWh - (52.2 / 1800)) < 1e-12);
assert.ok(Math.abs(live.movingDischargedWh - (52.2 / 3600)) < 1e-12);
assert.ok(Math.abs(live.stationaryDischargedWh - (52.2 / 3600)) < 1e-12);
assert.equal(live.movingMs, 1000);
assert.equal(live.maximumSpeedMmPerSecond, 200);
} finally {
Date.now = originalNow;
}
});
test('uses cumulative odometer distance without recalculating encoder movement', () => {
const { collector } = makeHarness();
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1000, updatedAt: 4_000_000 } });
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1250, updatedAt: 4_001_000 } });
const live = collector.getLiveState()[0].minute;
assert.equal(live.distanceMm, 250);
});
test('aggregates drive commands into minute counters instead of event noise', () => {
const { collector, writes } = makeHarness();
const originalNow = Date.now;
Date.now = () => 2_000_000;
try {
for (let index = 0; index < 100; index += 1) {
collector.collectCommand({ roverId: 'alpha', type: 'drive', outcome: 'issued', ts: 2_000_000 + index });
}
collector.collectCommand({ roverId: 'alpha', type: 'drive', outcome: 'rejected', error: 'safety cooldown' });
collector.collectCommand({ roverId: 'alpha', type: 'horn', outcome: 'issued' });
const live = collector.getLiveState()[0].minute;
assert.equal(live.commandCount, 102);
assert.equal(live.driveCommandCount, 101);
assert.equal(live.rejectedCommandCount, 1);
assert.equal(writes.events.length, 2);
assert.deepEqual(writes.events.map((event) => event.type), ['command.rejected', 'command.issued']);
} finally {
Date.now = originalNow;
}
});
test('preserves chat content in structured global events', () => {
const { collector, writes } = makeHarness();
collector.collectEvent({
source: 'chat',
type: 'chat:message',
ts: 3_000_000,
payload: { text: 'hello fleet history', nickname: 'Otter' },
});
assert.equal(writes.events.length, 1);
assert.equal(writes.events[0].payload.text, 'hello fleet history');
assert.equal(writes.events[0].visibility, 'global');
});
@@ -0,0 +1,119 @@
// Fleet Report Service
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport.
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets.
const { loadConfig } = require('../../helpers/configLoader');
const { isFeatureEnabled } = require('../../helpers/features');
const logger = require('../../globals/logger').child('fleetReportService');
if (!isFeatureEnabled('fleetReports')) {
module.exports = {
enabled: false,
getDailyReport: () => null,
};
} else {
const { subscribeAll } = require('../eventBus');
const roverManager = require('../roverManager');
const { commandEvents } = require('../commandService');
const { odometerEvents } = require('../odometerService');
const { createStorage } = require('./storage');
const { createCollector } = require('./collector');
const { createReportBuilder } = require('./reportBuilder');
const { registerSocketGateway } = require('./socketGateway');
const config = loadConfig().fleetReports || {};
const batteryConfig = config.battery || {};
const retentionConfig = config.retention || {};
const maximumIntegrationGapMs = Math.max(
250,
(Number(batteryConfig.maximumIntegrationGapSeconds) || 5) * 1000,
);
const minimumCapacityTestDepthPercent = Math.max(
10,
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
);
const batteryEnabled = batteryConfig.enabled !== false;
const storage = createStorage({ logger });
const collector = createCollector({
storage,
logger,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
});
const reportBuilder = createReportBuilder({ storage, collector, roverManager });
storage.open();
const unsubscribeEvents = subscribeAll(collector.collectEvent);
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
commandEvents.on('observation', collector.collectCommand);
odometerEvents.on('update', collector.collectOdometer);
const managerEventKinds = ['rover', 'hostStats', 'driver', 'switch', 'lock', 'private', 'privateSafety'];
const managerEventHandlers = new Map(managerEventKinds.map((kind) => {
const handler = (event) => collector.collectManagerEvent(kind, event);
roverManager.managerEvents.on(kind, handler);
return [kind, handler];
}));
registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger });
// Periodic upserts bound data-loss on an unclean shutdown while still
// avoiding writes at the 20 Hz sensor-frame rate.
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
flushTimer.unref?.();
function retentionDays(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : fallback;
}
function pruneNow() {
const now = Date.now();
const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
const minuteDays = retentionDays(retentionConfig.minuteSamplesDays, 0);
storage.prune({
detailedBefore: detailedDays === 0 ? 0 : now - detailedDays * 86400000,
minuteBefore: minuteDays === 0 ? 0 : now - minuteDays * 86400000,
});
}
pruneNow();
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
retentionTimer.unref?.();
function getDailyReport({ since, until, roverIds } = {}) {
const end = Number(until) || Date.now();
return reportBuilder.build({
since: Number(since) || end - 24 * 60 * 60 * 1000,
until: end,
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
// Daily Discord output is intentionally metric-only. Avoiding the event
// query here also prevents irrelevant event volume from bloating the
// durable daily snapshot that supports delivery idempotency.
includeEvents: false,
});
}
logger.info('Fleet reporting enabled', {
databaseAvailable: storage.getDiagnostics().available,
maximumIntegrationGapMs,
minimumCapacityTestDepthPercent,
batteryEnabled,
});
module.exports = {
enabled: true,
getDailyReport,
collector,
storage,
reportBuilder,
// Exposed for controlled tests and graceful future shutdown wiring. Normal
// runtime leaves subscriptions active for the lifetime of the server.
stop() {
unsubscribeEvents();
if (batteryEnabled) roverManager.managerEvents.off('sensor', collector.collectSensor);
commandEvents.off('observation', collector.collectCommand);
odometerEvents.off('update', collector.collectOdometer);
managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
clearInterval(flushTimer);
clearInterval(retentionTimer);
collector.flushMinutes();
},
};
}
@@ -0,0 +1,298 @@
// Fleet Report Builder
// Purpose: Produces fleet-wide battery-health and energy-efficiency read models from passive evidence.
// Scope: Keeps estimation, confidence, and comparison policy out of collection, transport, Discord, and UI code.
const MINIMUM_EFFICIENCY_DISTANCE_MM = 25 * 1000;
function sum(rows, key) {
return rows.reduce((total, row) => total + (Number(row?.[key]) || 0), 0);
}
function median(values) {
const usable = values.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
if (!usable.length) return null;
const middle = Math.floor(usable.length / 2);
return usable.length % 2 ? usable[middle] : (usable[middle - 1] + usable[middle]) / 2;
}
function weightedAverage(rows, valueKey, weightKey = 'sampleCount') {
const weighted = rows.reduce((result, row) => {
const value = Number(row[valueKey]);
const weight = Number(row[weightKey]);
if (!Number.isFinite(value) || !Number.isFinite(weight) || weight <= 0) return result;
result.total += value * weight;
result.weight += weight;
return result;
}, { total: 0, weight: 0 });
return weighted.weight ? weighted.total / weighted.weight : null;
}
function minimum(rows, key) {
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
return values.length ? Math.min(...values) : null;
}
function maximum(rows, key) {
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
return values.length ? Math.max(...values) : null;
}
function confidenceForObservationCount(count, averageDepthPercent) {
/*
Confidence is intentionally continuous evidence summarized into a label,
not a pass/fail cycle judgment. Multiple partial observations can become
strong evidence, while shallow observations remain visible and useful.
*/
if (count >= 5 && averageDepthPercent >= 25) return 'high';
if (count >= 2 && averageDepthPercent >= 10) return 'medium';
return 'low';
}
function buildBatteryHealth({ rover, sessions, registryEntry }) {
const referenceMah = Number(registryEntry?.ratedCapacityMah) || Number(rover.reportedCapacityMah) || null;
const batteryKey = registryEntry?.batteryKey
|| sessions[0]?.batteryKey
|| `unregistered:${rover.roverId}`;
const sameBattery = sessions.filter((session) => session.batteryKey === batteryKey);
const observations = sameBattery.flatMap((session) => {
if (session.kind !== 'discharging' || !referenceMah) return [];
const chargeDropMah = Number(session.startChargeMah) - Number(session.endChargeMah);
const dischargedMah = Number(session.dischargedMah);
if (!Number.isFinite(chargeDropMah) || chargeDropMah < 100 || !Number.isFinite(dischargedMah) || dischargedMah <= 0) {
return [];
}
const depthPercent = chargeDropMah / referenceMah * 100;
/*
Packet 25 provides the changing charge position while signed current
supplies an independent coulomb count. Extrapolating each partial slice
produces a capacity observation without requiring a full-to-empty run.
Depth is retained so callers can see exactly how much evidence supports
the estimate.
*/
return [{
startedAt: session.startedAt,
endedAt: session.endedAt,
depthPercent,
estimatedUsableMah: dischargedMah / (chargeDropMah / referenceMah),
gapCount: Number(session.gapCount) || 0,
}];
});
const cleanObservations = observations.filter((observation) => observation.gapCount <= 1);
const measuredUsableMah = median(cleanObservations.map((observation) => observation.estimatedUsableMah));
const observedChargeHighMah = maximum(rover.minutes, 'maxChargeMah');
const observedChargeLowMah = minimum(rover.minutes, 'minChargeMah');
const observedUsableFloorMah = observedChargeHighMah != null && observedChargeLowMah != null
? Math.max(0, observedChargeHighMah - observedChargeLowMah)
: null;
const averageDepthPercent = cleanObservations.length
? sum(cleanObservations, 'depthPercent') / cleanObservations.length
: 0;
const baselineMah = Number(registryEntry?.healthyBaselineMah) || referenceMah;
const capacityRetentionPercent = measuredUsableMah && baselineMah
? measuredUsableMah / baselineMah * 100
: null;
const nominalVoltageMv = rover.averageVoltageMv;
return {
batteryKey,
referenceMah,
baselineMah,
measuredUsableMah,
measuredUsableWh: measuredUsableMah && nominalVoltageMv
? measuredUsableMah * nominalVoltageMv / 1e6
: null,
capacityRetentionPercent,
observedUsableFloorMah,
observedChargeHighMah,
observedChargeLowMah,
observationCount: cleanObservations.length,
averageObservationDepthPercent: averageDepthPercent,
confidence: confidenceForObservationCount(cleanObservations.length, averageDepthPercent),
confidenceReason: cleanObservations.length
? `${cleanObservations.length} partial current/charge observations averaging ${averageDepthPercent.toFixed(1)}% depth`
: 'collecting partial discharge evidence',
dischargedThroughputMah: sum(sameBattery, 'dischargedMah'),
latestObservationAt: cleanObservations.reduce(
(latest, observation) => Math.max(latest, Number(observation.endedAt) || Number(observation.startedAt) || 0),
0,
) || null,
observations: cleanObservations,
};
}
function groupByRover({ minutes, sessions, roster, batteryRegistry }) {
const rosterById = new Map(roster.map((rover) => [String(rover.id), rover]));
const minuteGroups = new Map();
minutes.forEach((minute) => {
const roverId = String(minute.roverId);
if (!minuteGroups.has(roverId)) minuteGroups.set(roverId, []);
minuteGroups.get(roverId).push(minute);
});
return Array.from(new Set([...rosterById.keys(), ...minuteGroups.keys()])).map((roverId) => {
const rows = minuteGroups.get(roverId) || [];
const distanceMm = sum(rows, 'distanceMm');
const dischargedWh = sum(rows, 'dischargedWh');
const movingDischargedWh = sum(rows, 'movingDischargedWh');
const movingMs = sum(rows, 'movingMs');
const latest = rows[rows.length - 1] || null;
const base = {
roverId,
name: rosterById.get(roverId)?.name || roverId,
color: rosterById.get(roverId)?.color || null,
online: Boolean(rosterById.get(roverId)),
minutes: rows,
sampleCount: sum(rows, 'sampleCount'),
coverageMs: sum(rows, 'coverageMs'),
gapCount: sum(rows, 'gapCount'),
distanceMm,
movingMs,
averageSpeedMmPerSecond: movingMs ? distanceMm / (movingMs / 1000) : null,
maximumSpeedMmPerSecond: maximum(rows, 'maximumSpeedMmPerSecond'),
chargedMah: sum(rows, 'chargedMah'),
dischargedMah: sum(rows, 'dischargedMah'),
chargedWh: sum(rows, 'chargedWh'),
dischargedWh,
movingDischargedWh,
stationaryDischargedWh: sum(rows, 'stationaryDischargedWh'),
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? dischargedWh / (distanceMm / 1e6)
: null,
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? movingDischargedWh / (distanceMm / 1e6)
: null,
efficiencyDistanceRequiredMm: Math.max(0, MINIMUM_EFFICIENCY_DISTANCE_MM - distanceMm),
averageVoltageMv: weightedAverage(rows, 'avgVoltageMv'),
averageCurrentMa: weightedAverage(rows, 'avgCurrentMa'),
minimumVoltageMv: minimum(rows, 'minVoltageMv'),
maximumVoltageMv: maximum(rows, 'maxVoltageMv'),
averageTemperatureC: weightedAverage(rows, 'avgTemperatureC'),
minimumTemperatureC: minimum(rows, 'minTemperatureC'),
maximumTemperatureC: maximum(rows, 'maxTemperatureC'),
latestChargeMah: latest?.lastChargeMah ?? null,
reportedCapacityMah: latest?.reportedCapacityMah ?? null,
lastSampleAt: latest ? latest.bucketTs + 60000 : null,
};
const registryEntry = batteryRegistry.find((battery) =>
String(battery.roverId) === roverId && battery.retiredAt == null,
);
base.batteryHealth = buildBatteryHealth({
rover: base,
sessions: sessions.filter((session) => String(session.roverId) === roverId),
registryEntry,
});
delete base.minutes;
return base;
}).sort((a, b) => a.name.localeCompare(b.name));
}
function buildAttention(roverRows, now) {
const attention = [];
roverRows.forEach((rover) => {
if (!rover.sampleCount) {
attention.push({
key: `telemetry:${rover.roverId}`,
roverId: rover.roverId,
severity: 'notice',
title: rover.online ? 'Battery metrics unavailable in this range' : 'Rover was not observed in this range',
});
}
if (rover.maximumTemperatureC >= 45) {
attention.push({
key: `temperature:${rover.roverId}`,
roverId: rover.roverId,
severity: rover.maximumTemperatureC >= 50 ? 'critical' : 'warning',
title: `Battery reached ${rover.maximumTemperatureC} °C`,
});
}
if (rover.batteryHealth.capacityRetentionPercent != null
&& rover.batteryHealth.confidence !== 'low'
&& rover.batteryHealth.capacityRetentionPercent < 80) {
attention.push({
key: `capacity:${rover.roverId}`,
roverId: rover.roverId,
severity: rover.batteryHealth.capacityRetentionPercent < 65 ? 'critical' : 'warning',
title: `Estimated usable capacity is ${rover.batteryHealth.capacityRetentionPercent.toFixed(1)}% of baseline`,
});
}
if (rover.online && rover.lastSampleAt && now - rover.lastSampleAt > 5 * 60 * 1000) {
attention.push({
key: `stale:${rover.roverId}`,
roverId: rover.roverId,
severity: 'warning',
title: 'Battery metrics are stale',
});
}
});
const rank = { critical: 0, warning: 1, notice: 2 };
return attention.sort((a, b) => rank[a.severity] - rank[b.severity]);
}
function createReportBuilder({ storage, collector, roverManager }) {
function build({ since, until, roverIds, includeEvents = false, eventLimit = 500 }) {
const visibleRoster = Array.from(roverManager.rovers?.values?.() || []).map((record) => ({
id: record.id,
name: record.name || record.id,
color: record.color || record.meta?.color || null,
}));
const requestedIds = Array.isArray(roverIds) ? roverIds.map(String) : null;
const roster = requestedIds
? visibleRoster.filter((rover) => requestedIds.includes(String(rover.id)))
: visibleRoster;
const effectiveIds = requestedIds || roster.map((rover) => String(rover.id));
const minutes = storage.listMinutes({ since, until, roverIds: effectiveIds });
const batterySessions = storage.listBatterySessions({ since, until, roverIds: effectiveIds, limit: 2000 });
const batteryRegistry = storage.listBatteries(effectiveIds);
const roverRows = groupByRover({ minutes, sessions: batterySessions, roster, batteryRegistry });
const attention = buildAttention(roverRows, Date.now());
const distanceMm = sum(roverRows, 'distanceMm');
const dischargedWh = sum(roverRows, 'dischargedWh');
const movingDischargedWh = sum(roverRows, 'movingDischargedWh');
return {
generatedAt: Date.now(),
range: { since, until },
methodology: {
minimumEfficiencyDistanceMm: MINIMUM_EFFICIENCY_DISTANCE_MM,
historicalWhAvailable: false,
},
totals: {
roverCount: roverRows.length,
onlineRoverCount: roverRows.filter((rover) => rover.online).length,
distanceMm,
movingMs: sum(roverRows, 'movingMs'),
chargedWh: sum(roverRows, 'chargedWh'),
dischargedWh,
movingDischargedWh,
stationaryDischargedWh: sum(roverRows, 'stationaryDischargedWh'),
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? dischargedWh / (distanceMm / 1e6)
: null,
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
? movingDischargedWh / (distanceMm / 1e6)
: null,
attentionCount: attention.filter((item) => item.severity !== 'notice').length,
},
rovers: roverRows,
attention,
batteryRegistry,
dailyReportHistory: storage.listDailyReports(365),
// Events remain available only for explicit advanced/debug consumers.
// Neither the normal UI nor Discord requests them.
events: includeEvents
? storage.listEvents({ since, until, roverIds: effectiveIds, limit: eventLimit })
: [],
diagnostics: {
collector: collector.getDiagnostics(),
storage: storage.getDiagnostics(),
},
};
}
return { build };
}
module.exports = {
MINIMUM_EFFICIENCY_DISTANCE_MM,
createReportBuilder,
};
@@ -0,0 +1,96 @@
// Fleet Report Socket Gateway
// Purpose: Exposes read-only, visibility-filtered fleet history to browser clients.
// Scope: Owns query validation and existing private/lockdown access boundaries; it performs no collection or analysis.
const io = require('../../globals/io');
const crypto = require('crypto');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const MAX_RANGE_MS = 366 * 24 * 60 * 60 * 1000;
function normalizeRange(payload = {}) {
const now = Date.now();
const until = Number.isFinite(Number(payload.until)) ? Number(payload.until) : now;
const requestedSince = Number.isFinite(Number(payload.since))
? Number(payload.since)
: until - 24 * 60 * 60 * 1000;
const since = Math.max(0, Math.max(requestedSince, until - MAX_RANGE_MS));
return { since, until: Math.max(since + 1, until) };
}
function registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger }) {
io.on('connection', (socket) => {
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
try {
const { since, until } = normalizeRange(payload);
// getRosterForSocket is the canonical live private-rover visibility
// resolver. Historical queries use precisely those currently visible
// rover IDs so fleet totals cannot indirectly disclose a private rover.
const visibleRoverIds = roverManager.getRosterForSocket(socket).map((rover) => String(rover.id));
const requestedIds = Array.isArray(payload.roverIds)
? payload.roverIds.map(String).filter((id) => visibleRoverIds.includes(id))
: visibleRoverIds;
const report = reportBuilder.build({
since,
until,
roverIds: requestedIds,
includeEvents: payload.includeEvents !== false,
eventLimit: payload.eventLimit,
});
// Lockdown-only events are deliberately removed after query assembly.
// They are global rather than rover-scoped, so rover filtering alone is
// insufficient to preserve the pre-existing lockdown privacy boundary.
if (!isLockdownAdmin(socket)) {
report.events = report.events.filter((event) => event.visibility !== 'lockdown');
report.totals.eventCountReturned = report.events.length;
}
if (payload.compact === true) {
// The Activities card now consumes the same all-rovers metric rows
// as fullscreen. The builder no longer attaches minute/session
// evidence by default, so compacting only removes archival metadata.
report.events = [];
report.dailyReportHistory = [];
}
cb({ ok: true, report });
} catch (err) {
logger.warn('Fleet report query failed', { socketId: socket.id, error: err.message });
cb({ error: 'Fleet report query failed' });
}
});
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) throw new Error('Admin access required');
const roverId = String(payload.roverId || '').trim();
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
const ratedCapacityMah = Number(payload.ratedCapacityMah);
if (!Number.isFinite(ratedCapacityMah) || ratedCapacityMah <= 0 || ratedCapacityMah > 65535) {
throw new Error('Rated capacity must be between 1 and 65535 mAh');
}
const installedAt = Number.isFinite(Number(payload.installedAt)) ? Number(payload.installedAt) : Date.now();
const entry = storage.replaceBattery({
roverId,
batteryKey: `battery:${roverId}:${installedAt}:${crypto.randomUUID().slice(0, 8)}`,
chemistry: String(payload.chemistry || '').trim() || null,
ratedCapacityMah: Math.round(ratedCapacityMah),
installedAt,
notes: String(payload.notes || '').trim() || null,
});
if (!entry) throw new Error('Battery registry write failed');
collector.refreshBatteryIdentity(roverId);
collector.collectEvent({
source: 'fleetReportService',
type: 'battery.replaced',
payload: { roverId, battery: entry },
});
cb({ ok: true, battery: entry });
} catch (err) {
logger.warn('Fleet battery replacement rejected', { socketId: socket.id, error: err.message });
cb({ error: err.message });
}
});
});
}
module.exports = {
registerSocketGateway,
};

Some files were not shown because too many files have changed in this diff Show More