Compare commits

...
171 Commits
Author SHA1 Message Date
legop3 609eb6c35e always let through audio forwarding
Container image / image (push) Waiting to run
2026-09-15 16:35:55 -04:00
legop3 c3c60fde18 switchreplay workers to rtsp 2026-09-15 16:27:08 -04:00
legop3 aa3c0c0e7b improve admin user list ui 2026-09-15 15:53:14 -04:00
legop3 8b5d7372f9 get lifecycle out of data 2026-09-15 15:00:22 -04:00
legop3 7539898f98 fix restore breaking lifecycle stuff 2026-09-15 14:39:17 -04:00
legop3 73255831f1 fix discord admin id stuff 2026-09-15 13:18:24 -04:00
legop3 3b585b06b4 smallify the compose yaml 2026-09-15 13:14:42 -04:00
legop3 55ee6e05bb container manager update and restart stuffs 2026-09-15 13:06:50 -04:00
legop3 5d4f7fe5cc docker healthcheck api thingy 2026-09-15 12:33:45 -04:00
legop3 bdb32d13ac switch to docker volume for data folder, and get real ffmpeg from rpm fusion for replays 2026-09-15 12:09:40 -04:00
legop3 6b6fbcf871 unignore package-locks for the action build to have reproducible stable buildings
Container image / image (push) Waiting to run
2026-09-15 01:15:48 -04:00
legop3 5a81ff7716 dockerfile and ghcr action!! 2026-09-15 01:10:51 -04:00
legop3 470e95b0f9 fix inter instance config broken stuffs 2026-09-14 20:52:01 -04:00
legop3 9ce6550f13 fix sticky 2026-09-14 20:30:48 -04:00
legop3 ef9baf6063 some UI tweaking before testing in production lol 2026-09-14 20:12:11 -04:00
legop3 43274e7371 /video signaling proxy and a global public server http path config item 2026-09-14 19:49:37 -04:00
legop3 e7d7f2a270 backup restore slopfix 2026-09-14 19:17:48 -04:00
legop3 1c34849bd0 backup / restoreslop 2026-09-14 19:09:02 -04:00
legop3 edc1b825f2 server restart thingy yay 2026-09-14 18:42:42 -04:00
legop3 c56a8b1000 legacy importer in admin page aswell as setup. 2026-09-14 18:10:45 -04:00
legop3 91e0cc3886 service live configslop 2026-09-14 17:56:53 -04:00
legop3 881583ee0a yaml importer slopping it up 2026-09-14 13:48:58 -04:00
legop3 8ff3c39765 better colorses 2026-09-14 13:30:59 -04:00
legop3 9b5c187aac cardframing again 2026-09-14 13:27:48 -04:00
legop3 0266bd9568 leg 2026-09-14 13:04:41 -04:00
legop3 6edb6f6dd0 old defaults in new schema 2026-09-14 12:54:29 -04:00
legop3 ec8eb1c002 cardframing 2026-09-14 12:41:25 -04:00
legop3 3c385126ae descriptionslop 2026-09-14 12:34:22 -04:00
legop3 4635e1b40c slorp 2026-09-14 12:15:59 -04:00
legop3 3d2e75572f config uislopping 2026-09-14 03:06:41 -04:00
legop3 81994f8a56 remove useless slop stuff 2026-09-14 02:49:22 -04:00
legop3 bfdb6555d8 this is a big slop that might backfire lol... new config system and UI! 2026-09-14 02:31:12 -04:00
legop3 17b1404157 converge everything into one data folder 2026-09-13 22:10:15 -04:00
legop3 b199f45eb2 add bars to thingy flingy 2026-09-13 15:43:41 -04:00
legop3 0f08fb3f0d Merge pull request #25 from legop3/newcontroller
Newcontroller
2026-09-13 02:09:38 -04:00
legop3 0f5a33c1de slop tank steering 2026-09-13 01:41:32 -04:00
legop3 8e96c3cdae glorp! 2026-09-12 20:52:34 -04:00
legop3 ba5c1c5d25 dont stop assignments for help rovers, just allow people to leave them. 2026-09-12 18:58:32 -04:00
legop3 6a914faffb Merge pull request #24 from legop3/HELP
Help
2026-09-12 18:34:35 -04:00
legop3 3b99590b3b lowe note one octave 2026-09-12 18:32:44 -04:00
legop3 5acbf6e0bf fix song hopefully? 2026-09-12 18:14:43 -04:00
legop3 3ec45de4b4 also beep roomba at the same time 2026-09-12 17:44:12 -04:00
legop3 3b7b2ac21e horn beep help thing yay 2026-09-12 17:17:18 -04:00
legop3 02a32e2524 fix help while docked lol 2026-09-12 17:04:21 -04:00
legop3 eb1fab50e4 adjust flashings 2026-09-12 16:43:36 -04:00
legop3 f85e258c29 adjust flashings 2026-09-12 16:41:23 -04:00
legop3 72b8db8a31 dont like shadow 2026-09-12 16:39:40 -04:00
legop3 fb31ff52bd HELP!!!! 2026-09-12 16:16:59 -04:00
legop3 99d2a7689f todoing 2026-09-12 15:23:04 -04:00
legop3 124369dfd7 forgot to pi build?? idk 2026-09-12 14:54:41 -04:00
legop3 4e24b5437d Merge pull request #23 from legop3/esp32io
Esp32io
2026-09-12 14:44:19 -04:00
legop3 a3c13f3dd3 fix title dark 2026-09-12 14:35:10 -04:00
legop3 b0389b5ddc ui tweak fling 2026-09-12 14:31:17 -04:00
legop3 9ca039229a slopping up a platformio library for people to make rover peripherals 2026-09-09 18:43:39 -04:00
legop3 8895ed6bd8 fixfix 2026-09-08 23:56:45 -04:00
legop3 6ca7cc0cf0 adjusting stylings 2026-09-08 23:52:25 -04:00
legop3 d3fd3946e6 uiuiui 2026-09-08 23:11:03 -04:00
legop3 038ae0f45a add consolenotifier for peripheral system alerts 2026-09-08 21:46:10 -04:00
legop3 3859806fca testings are going goods 2026-09-08 21:10:04 -04:00
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
476 changed files with 40735 additions and 4971 deletions
+16
View File
@@ -0,0 +1,16 @@
# Docker Build Context
# Purpose: Keeps local state, cached dependencies, and host-built artifacts out
# of the production image build context. Every required artifact is recreated
# by the Dockerfile from tracked source and lockfiles.
.git
.gitignore
**/node_modules
# Neither the legacy state directory nor the Compose-mounted replacement may
# enter an image build. This prevents credentials and backups from becoming
# image layers after an operator has started using either deployment layout.
/server/data
/data
server/public
server/src/services/kinectService/native/kinect_worker
server/src/services/balanceBoardService/native/balance_board_worker
**/*.log
+67
View File
@@ -0,0 +1,67 @@
# Build the exact production image on pull requests, publish branch-named images
# for development, and replace `latest` only after a successful main-branch
# push. Keeping every channel in one job prevents a second build path from
# drifting away from what servers actually download.
name: Container image
on:
pull_request:
# Every repository branch gets one moving development image. GitHub does not
# grant package-write access to untrusted fork pull requests, which remain
# build-only through the separate pull_request event above.
push:
# Repository contents are read to build the image. Package write access is used
# only by the conditional GHCR login and push steps on repository branch pushes.
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7
# Buildx supplies the cache and the explicit amd64 build used both for
# pull-request verification and publication. QEMU is intentionally absent
# because the central server image supports only linux/amd64.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# GitHub's built-in token can publish to this repository's package. Pull
# requests never authenticate to GHCR and therefore cannot publish.
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Docker's maintained metadata action converts branch names into valid
# container tags, including replacing separators such as `/`. Main gets
# only `latest`; every other pushed branch gets only its branch tag.
- name: Select image tag
id: image-metadata
uses: docker/metadata-action@v6
with:
images: ghcr.io/legop3/multiroombarover
flavor: latest=false
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,enable={{is_not_default_branch}}
# The push switch keeps pull requests build-only. Branch images are
# replaced on each successful push, just as main replaces `latest`.
- name: Build and optionally publish
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.image-metadata.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+12 -4
View File
@@ -13,23 +13,31 @@ config.h
robots.json
roverd-dummy
server/config.yaml
server/package-lock.json
package-lock.json
server/data/discord-guilds.json
server/data/global-objective.json
server/data/admin-reason.json
server/data/buttonbox-state.json
server/data/barcode-tts-cache/
server/data/rover-odometers.json
webui/package-lock.json
server/data/mediamtx.yml
# npm lockfiles are deliberately tracked. The production image uses `npm ci`,
# so a clean GitHub checkout must contain the exact dependency resolution used
# by local builds rather than resolving a different dependency tree.
!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
server/data/configuration.sqlite
server/data/configuration.sqlite-shm
server/data/configuration.sqlite-wal
+190
View File
@@ -0,0 +1,190 @@
# syntax=docker/dockerfile:1
# MultiRover Production Application Image
# Purpose: Builds the web application, Node dependencies, native hardware
# workers, and pinned runtime tools into one amd64 server image.
# Scope: Packages the application and its private controller command in one
# image. Compose still isolates their processes, mounts, and privileges.
ARG FEDORA_VERSION=43
FROM fedora:${FEDORA_VERSION} AS architecture-check
ARG TARGETARCH
# The central server is currently deployed and verified only on amd64. Failing
# here avoids publishing an ARM image whose native workers and hardware paths
# have never been exercised on a real ARM server.
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
FROM architecture-check AS webui-build
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
&& dnf clean all
WORKDIR /build
# Copy dependency manifests first so ordinary source edits retain the expensive
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
# source rather than resolving a new tree during image publication.
COPY webui/package.json webui/package-lock.json ./webui/
RUN --mount=type=cache,target=/root/.npm \
cd webui && npm ci
COPY webui ./webui
# Vite deliberately emits into ../server/public. Create that destination in the
# isolated builder and copy only its finished files into the runtime stage.
RUN mkdir -p server/public && cd webui && npm run build
FROM architecture-check AS server-dependencies
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
&& dnf clean all
WORKDIR /build/server
COPY server/package.json server/package-lock.json ./
# Native Node modules compile here when a matching prebuild is unavailable;
# neither the compiler nor npm's download cache is copied into the final image.
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM architecture-check AS native-workers
RUN dnf install -y --setopt=install_weak_deps=False \
bluez-libs-devel \
gcc-c++ \
libcap \
libfreenect-devel \
libusb1-devel \
make \
pkgconf-pkg-config \
wiiuse-devel \
&& dnf clean all
WORKDIR /build
COPY server/src/services/kinectService/native ./kinect
COPY server/src/services/balanceBoardService/native ./balance-board
# Build both hardware workers from source for the image's Fedora ABI instead of
# copying workstation binaries whose linked libraries may not match.
RUN make -C kinect \
&& make -C balance-board
FROM architecture-check AS packaged-tools
ARG MEDIAMTX_VERSION=1.15.3
ARG MEDIAMTX_SHA256=cddc98d17f23689848d5a935151264e117cfb27cee2db87b5079b19572e4b48d
ARG NEOLINK_VERSION=0.6.2
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
ARG GOOGLE_TTS_VERSION=26.5
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
&& dnf clean all
WORKDIR /build/tools
# Each external artifact is pinned and checked before extraction. A changed or
# truncated upstream download therefore fails the image build instead of being
# silently promoted as the new production server.
RUN curl --fail --location --retry 3 \
"https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}/mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz" \
--output mediamtx.tar.gz \
&& echo "${MEDIAMTX_SHA256} mediamtx.tar.gz" | sha256sum --check --strict \
&& tar -xzf mediamtx.tar.gz mediamtx \
&& install -D -m 0755 mediamtx /output/usr/local/bin/mediamtx
RUN curl --fail --location --retry 3 \
"https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}/neolink_linux_x86_64_ubuntu.zip" \
--output neolink.zip \
&& echo "${NEOLINK_SHA256} neolink.zip" | sha256sum --check --strict \
&& unzip -q neolink.zip -d neolink \
&& neolink_binary="$(find neolink -type f -name neolink -print -quit)" \
&& test -n "${neolink_binary}" \
&& install -D -m 0755 "${neolink_binary}" /output/usr/local/bin/neolink
RUN curl --fail --location --retry 3 \
"https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-${GOOGLE_TTS_VERSION}.tar.xz" \
--output googletts.tar.xz \
&& echo "${GOOGLE_TTS_SHA256} googletts.tar.xz" | sha256sum --check --strict \
&& tar -xf googletts.tar.xz en-us-x-multi.zvoice libchrometts_x86_64.so \
&& install -D -m 0644 libchrometts_x86_64.so /output/opt/roverd/googletts/libchrometts.so \
&& mkdir -p /output/opt/roverd/googletts/en-us-x-multi-r30 \
&& unzip -q en-us-x-multi.zvoice -d /output/opt/roverd/googletts/en-us-x-multi-r30 \
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
FROM fedora:${FEDORA_VERSION} AS runtime
ARG FEDORA_VERSION
ARG TARGETARCH
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
# Fedora's restricted ffmpeg-free build omits the libx264 encoder used by every
# replay output path. Enable RPM Fusion Free before installing runtime packages
# so the image receives the complete FFmpeg build instead of requiring replay
# code to work around a deployment-only codec omission.
RUN dnf install -y --setopt=install_weak_deps=False \
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
# This is the complete runtime package set. Build headers and compilers live in
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
# because enabled services invoke them after startup. Weak dependencies are
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
# themes, and GPU drivers that a headless server neither starts nor uses.
RUN dnf install -y --setopt=install_weak_deps=False \
bluez \
bluez-libs \
espeak \
ffmpeg \
flite \
gstreamer1 \
gstreamer1-plugins-bad-free \
gstreamer1-plugins-base \
gstreamer1-plugins-good \
gstreamer1-rtsp-server \
libcxx \
libcxxabi \
libcap \
libfreenect \
libusb1 \
nodejs \
python3 \
shadow-utils \
tini \
wiiuse \
--allowerasing \
&& dnf clean all \
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
WORKDIR /opt/multirover/server
COPY server/index.js server/package.json server/package-lock.json ./
COPY server/src ./src
COPY server/assets ./assets
COPY server/prompts ./prompts
COPY --from=server-dependencies /build/server/node_modules ./node_modules
COPY --from=webui-build /build/server/public ./public
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
COPY --from=packaged-tools /output/ /
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
# Only the audited Balance Board bridge receives its two required socket
# capabilities. Node and the rest of the application continue to run without
# ambient capabilities; Compose must also allow these capabilities when the
# optional Balance Board feature is used.
RUN setcap cap_net_admin,cap_net_bind_service+ep \
./src/services/balanceBoardService/native/balance_board_worker \
&& /usr/local/bin/chromegtts-wav \
--text "test" \
--voice tpf \
--pitch 1 \
--speed 1 \
--output /tmp/chromegtts-smoke.wav \
&& test -s /tmp/chromegtts-smoke.wav \
&& rm /tmp/chromegtts-smoke.wav
ENV NODE_ENV=production \
SERVER_DATA_DIR=/data \
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
USER multirover
# Declaring the persistence boundary also protects direct `docker run` users:
# when no explicit host path or named volume is supplied, Docker still places
# `/data` on an anonymous volume rather than the replaceable image layer.
VOLUME ["/data"]
EXPOSE 8080/tcp 8554/tcp 8189/tcp 8189/udp
# Use Node's built-in fetch so container readiness does not require curl or a
# second probe binary in the runtime image. The endpoint verifies the writable
# data mount and MediaMTX; reaching it already proves Node is accepting HTTP.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "index.js"]
# OCI metadata links the unversioned latest image to its source without
# introducing release numbers or additional image tags.
LABEL org.opencontainers.image.source="https://github.com/legop3/MultiRoombaRover" \
org.opencontainers.image.title="MultiRoombaRover"
+56
View File
@@ -0,0 +1,56 @@
# MultiRover production deployment
name: multirover
# Change this one line to use a development branch image.
x-multirover-image: &multirover-image ghcr.io/legop3/multiroombarover:latest
services:
server:
image: *multirover-image
container_name: multirover
network_mode: host
restart: unless-stopped
stop_grace_period: 20s
security_opt:
- label=disable
volumes:
# All persistent application data is stored in this volume.
- data:/data
- lifecycle-socket:/run/multirover
# Required for Bluetooth hardware such as the Balance Board.
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
# Required for Kinect USB access.
devices:
- /dev/bus/usb:/dev/bus/usb
cap_add:
- NET_ADMIN
lifecycle:
image: *multirover-image
container_name: multirover-lifecycle
command: ["node", "src/services/serverControlService/controller.js"]
user: root
network_mode: none
restart: unless-stopped
healthcheck:
disable: true
security_opt:
- label=disable
environment:
MULTIROVER_TARGET_IMAGE: *multirover-image
volumes:
- lifecycle-socket:/run/multirover
# Do not add this Docker socket mount to the server service.
- /var/run/docker.sock:/var/run/docker.sock
volumes:
# `docker compose down -v` permanently deletes these volumes.
data:
name: multirover-data
lifecycle-socket:
name: multirover-lifecycle-socket
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
+786
View File
@@ -0,0 +1,786 @@
# Server administration and container migration
## Status
This document is the live implementation tracker for the migration.
- [x] Phase 1, step 1: Establish the single data-directory contract
- [x] Phase 1, steps 2-5: Configuration database, manual setup-file import, setup, and centralized admin UI
- [x] Phase 1, steps 6-8: Restart, backup/restore, and internal video proxy
- [x] Phase 1, step 9: Complete the remaining legacy-deployment integration and hardware verification
- [x] Phase 2, step 10: Build and locally verify the production application image
- [x] Phase 2, steps 11-12: Add the single-container Compose deployment and locally verify its host-access contract
- [x] Phase 2, step 13: Add application and container health checks
- [x] Phase 2, step 14: Add the restricted lifecycle container and connect the System UI
- [x] Phase 2, step 15: Build pull requests and publish the main branch to the single GHCR `latest` channel
- [ ] Phase 2: Containerization, GHCR publishing, and container lifecycle controls
Phase 1 is complete. The current application has run successfully on the production server with the new configuration, administration, persistence, backup/restore, and internal video-proxy contracts.
The work is deliberately split into two phases:
1. Finish the server-side configuration, administration, persistence, backup, restore, and media-routing changes while the server still uses its current systemd deployment.
2. Containerize the already-finished application, publish images through GHCR, and add container-aware update and restart controls.
Phase 1 must be complete and verified before Phase 2 begins. Containerization must not become a second configuration migration or a reason to maintain two persistence layouts.
## Decision log
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
- 2026-09-14: Apply every committed configuration revision immediately. The configuration coordinator atomically replaces the process-wide snapshot, compares top-level service sections, serially reloads only affected service runtimes, and then refreshes all sessions. Long-lived HTTP/socket handlers remain registered once and delegate to the current runtime; integrations may reconnect or replace their own child process, worker, client, timers, and subscriptions without restarting Node.
- 2026-09-14: Render the schema-driven configuration editor as a YAML-like tree inside one `CardFrame`. Every object or array introduces an ordered header and one indentation guide, every scalar occupies one key/value row, and array operations remain beside their item instead of moving to the far edge. Keep all route-specific RJSF styling in `webui/src/admin/styles.css`, outside the shared global stylesheet.
- 2026-09-14: Restart only the application process, never the host. A lockdown administrator with recent password confirmation requests one audited restart, Node acknowledges and announces it, then sends itself SIGTERM. Existing service signal handlers clean up their owned children, while systemd `Restart=always` and the later container restart policy start the application again.
- 2026-09-14: Keep backup and restore together in one server service after application restart exists. Neither operation stops running services or writers, and no command-line interface is maintained. Backup uses online SQLite snapshots and stable copies of non-database files; restore validates and stages an uploaded archive, records a marker, and uses the normal application restart to replace the data directory during earliest startup.
- 2026-09-14: Define this server's canonical `publicUrl` once at the top of configuration beside `timezone`. Discord links, inter-instance identity, page metadata, and MediaMTX's primary public ICE hostname derive from it. Browser WHEP and WHIP signaling always uses the same-origin `/video` path; `media.additionalHosts` remains only for genuinely additional ICE names or addresses.
- 2026-09-14: Treat container deployment as a fresh installation. Neither startup nor the installer searches for, imports, removes, or otherwise manages an old `config.yaml`; the only old-file paths retained are operator-selected YAML uploads on `/setup` and the protected Configuration page. The separate command-line importer and its dry-run mode are removed. Internal SQLite schema migrations remain because they evolve the active database rather than discovering an old installation.
- 2026-09-14: Keep the one-time first-run setup code in `data/setup-code.txt` with owner-only permissions instead of writing the credential into server logs. Reuse it across restarts and delete it permanently when setup completes.
- 2026-09-14: Feature enablement is exactly the service-owned `enabled` boolean. A service-owned configuration definition marks itself with `feature: true` when that switch belongs in the public feature map; the configuration system derives the map for sessions and command availability, including nested service definitions, without a separate feature registry. Missing credentials, hardware, connections, data, or enabled dependencies are runtime health conditions and never silently change that choice.
- 2026-09-14: Keep configuration as one ordered hierarchical document, matching the former YAML layout. The admin application presents one continuous configuration page and saves the complete document as one revision. There are no artificial Hardware, Integrations, Media, or similar configuration categories and no backend or frontend section registries.
- 2026-09-13: Use an internal Node `/video` proxy. The public reverse proxy will send every site path to Node, Node will strip `/video` and stream WHEP signaling to MediaMTX on loopback, and MediaMTX port 8889 will not be exposed publicly. MediaMTX cannot independently add a WHEP base-path prefix; making `video` part of every stream name would still leave two HTTP servers competing for the public HTTPS listener.
- 2026-09-13: Preserve the existing flat `server/data` layout instead of moving established stores into decorative `state`, `cache`, or `generated` parents. Packaged application assets remain with the application.
- 2026-09-13: "The server" in the filesystem rule specifically means the main Node.js application. Every file it intentionally creates or modifies, including disposable scratch work, must be beneath `SERVER_DATA_DIR`. Installers, systemd, Docker, BlueZ, and unavoidable internal behavior of external libraries are outside that application boundary.
## Final goals
- `config.yaml` and `config.example.yaml` no longer exist.
- All operator-controlled server configuration is stored in a validated database and managed through the web UI.
- All mutable runtime state, generated files, caches, snapshots, recordings, and databases live under one server data directory.
- A complete backup can capture that one data directory consistently, and a restore can safely replace it.
- `/setup` may initialize the database from a YAML file explicitly selected by the operator, and the protected Configuration page may explicitly replace configuration from one later; no automatic host migration or persistent YAML source exists.
- A dedicated `/admin` application contains all server administration.
- The public `/video` route is proxied to MediaMTX by the Node server, eliminating the special external MediaMTX proxy rule.
- The completed server is packaged as a replaceable container whose only persistent mount is the data directory.
- The latest successful main-branch image is built automatically and published to GHCR as `ghcr.io/legop3/multiroombarover:latest`.
- The admin UI can restart, update, health-check, and roll back the application container without giving the main application direct Docker access.
- The final host installation contains as little project-specific material as possible: a Compose file, one Docker-managed data volume, and unavoidable hardware preparation.
## Important boundary: application files versus server data
The data-directory rule applies to everything mutable or instance-specific that the server reads, writes, generates, or persists at runtime. It does not mean copying the application itself into the data directory.
Packaged, read-only application material remains with the application and later inside the image:
- Server source and production dependencies
- Built web UI assets
- Static sound and image assets shipped by the repository
- MediaMTX, ffmpeg, ffprobe, neolink, and TTS tools
- Kinect and Balance Board workers
- Helper scripts shipped as part of the application
The single data directory owns:
- Server configuration and secrets
- Administrator accounts
- Identity and permission records
- Fleet reports
- Persistent service state
- Audit history
- Generated MediaMTX configuration
- Snapshots and replay segments
- Finished replays
- PTZ-generated audio
- Barcode/TTS caches
- Disposable audio-forward and replay-build work under `runtime/`
- Backup and restore coordination state
- Any future file deliberately created or modified by the Node application
Application-owned scratch work must use `SERVER_DATA_DIR/runtime`, even when it is safe to lose on restart. Tests may use the operating system temporary directory because they are not the running server application. Packaged programs and host services can manage their own internal temporary state, but any output path explicitly selected by Node must follow the single-root rule.
# Phase 1: complete the application before containerization
Phase 1 is server, web UI, installer, and migration work only. The current systemd deployment remains the runtime while these contracts are changed and verified.
## 1. Establish the single data-directory contract
The normal development and legacy-install location remains `server/data`. The path continues to be overridable through `SERVER_DATA_DIR`, which will later be set to `/data` in the container.
A representative final layout is:
```text
server/data/
├── configuration.sqlite
├── identity.sqlite
├── fleet-reports.sqlite
├── mediamtx.yml
├── existing service JSON stores
├── barcode-tts-cache/
├── rover-snapshots/
├── replay-segments/
├── replays/
├── ptz-camera-audio/
├── runtime/
│ ├── audio-forward/
│ ├── replay-builds/
│ └── room-camera-replay-builds/
└── system/
├── backup-staging/
└── restore/
```
The exact number of databases is not important. A centralized admin UI does not require unrelated services to share one SQLite connection. Keeping identity and high-volume fleet reporting in their existing databases may remain simpler, provided every database is under the same data directory.
Required work:
- Audit every server filesystem read and write.
- Make every persistent path resolve from the shared data-path helper.
- Move rover and PTZ snapshots out of `/var/lib/rover-snapshots` and into the data directory.
- Remove separate persistent replay path configuration and keep replay segments and completed replays under the data directory.
- Keep generated MediaMTX configuration at its established `data/mediamtx.yml` path.
- Keep barcode speech, PTZ speech, and similar caches under the data directory.
- Check native workers and child-process scripts for hidden working-directory assumptions.
- Update health reporting to inspect the new paths.
- Update the legacy installer so the service receives one `SERVER_DATA_DIR` rather than several unrelated persistent paths.
- Add a focused test that runs services against a temporary data directory and proves that no test artifact escapes it.
- Document which files are durable and which cache directories may be discarded.
The audit must search direct filesystem calls as well as environment-variable defaults. Existing calls that default to `/var/lib`, the repository directory, or an implicit current working directory must be corrected.
## 2. Replace YAML with a configuration database
Implementation architecture:
- Each configurable service owns a side-effect-free fragment containing its key, safe default, and strict schema. One short composition list assembles those fragments into the ordered hierarchical document.
- The database validates and commits that complete document as one coherent immutable revision.
- The admin UI presents one continuous configuration page in the same top-to-bottom order as the former YAML file.
- Nested cards make object relationships readable, but do not create separate categories, navigation destinations, persistence boundaries, or registries.
- Shared editor infrastructure owns loading, dirty state, validation errors, revision conflicts, secret operations, and live-application status for the whole document.
- The browser receives this same schema from the protected admin endpoint and renders it with a maintained JSON Schema form library.
- Standard JSON Schema types drive ordinary fields, nested objects, enums, and arrays. One field-agnostic widget handles every `writeOnly` secret; there are no feature-specific configuration components in React.
Create a synchronous configuration service backed by `better-sqlite3`. Synchronous reads preserve the server's current startup model, in which many services load their configuration while modules are required.
The configuration database should own at least:
- The current complete configuration document
- A monotonically increasing configuration revision
- Previous configuration revisions
- The administrator account catalog and password hashes
- Persistent administrative audit events
- Database/schema migration state
The configuration schema must explicitly describe every supported field. A validation library should be used rather than assembling an ad hoc validator by hand.
Current configuration areas to migrate include:
- Server timezone and public instance identity
- Administrator accounts and Discord identities
- Inter-instance directories and profile
- LLM commentary and Overseer Control
- Barcode games
- Media and WebRTC ICE candidates
- Bandwidth-saving policy
- Audio forwarding and global audio levels
- Home Assistant, Neato, lift, entities, and button mappings
- Room cameras and PTZ camera
- Kinect and Balance Board
- Button box and barcode scanner
- Command names
- Discord bot, channels, and roles
- Social links and driver content
- Fleet-report collection, retention, privacy, and delivery
Required behavior:
- A missing value receives a documented safe default.
- Optional integrations default to disabled.
- Unknown fields are rejected rather than silently ignored.
- Invalid configuration never becomes the active revision.
- A complete revision is written atomically.
- Updates include the acting administrator and timestamp.
- Concurrent editors use revision checking so an older browser cannot overwrite a newer change silently.
- Secrets are never included in ordinary configuration responses, logs, diffs, or audit metadata.
- Secret inputs support replace and clear operations without returning the current value to the browser.
- At least one lockdown administrator must always remain.
- An administrator cannot accidentally remove the only account capable of repairing administration.
Configuration changes use one intentionally simple application rule:
1. Validate the complete proposed document.
2. Commit it as a new database revision.
3. Atomically replace the process-wide configuration snapshot.
4. Compare the old and new top-level sections.
5. Reload every service that owns a changed section, replacing its complete internal runtime when necessary.
6. Report per-service application failures without preventing unrelated services from applying the revision.
7. Refresh sessions only after all affected service reloads finish.
HTTP routes, Socket.IO connection handlers, and process signal handlers are registered once. They consult live state or delegate to the current service runtime, preventing duplicate listeners after repeated saves. Service reloads may reconnect an integration or restart an application-owned child such as MediaMTX, ffmpeg, Kinect, or the Balance Board worker, but never restart the Node application.
Operational actions such as changing server mode, locking a rover, or issuing a rover command remain direct live actions rather than configuration edits.
After migration is complete:
- Remove the YAML configuration loader.
- Remove `SERVER_CONFIG`.
- Remove `config.yaml` and `config.example.yaml` from the repository and installation process.
- Remove `js-yaml` if MediaMTX generation is changed to avoid it or if it is otherwise no longer needed. Generated MediaMTX YAML is an internal artifact, not operator configuration, so retaining `js-yaml` solely for that generator is acceptable.
## 3. Add explicit configuration-file upload to setup and administration
Container deployment starts with a new data directory and never discovers an old installation automatically. As a convenience, the first-run setup page may initialize the empty database from a YAML configuration file deliberately selected by the operator. The protected Configuration page may later replace only the configuration from another explicitly selected legacy file. Neither path is a startup loader, installer migration, command-line workflow, or permanent second source of truth.
Every upload must:
- Accept only an explicitly selected YAML file from `/setup` or the protected Configuration page.
- Parse the complete document.
- Map every recognized field into the new configuration schema.
- Preserve secrets without printing them.
- Apply current defaults for absent fields.
- Ignore fields that do not exist in the current schema, while reporting invalid values supplied for current fields.
- Validate the entire result before writing anything.
- Record the uploaded filename without storing secret values in the audit event.
The setup upload additionally must:
- Require the one-time setup code before processing it.
- Preserve existing bcrypt administrator password hashes, lockdown roles, and Discord IDs.
- Refuse to replace an already-configured database.
- Write the configuration, administrators, and audit event atomically.
The initialized-server upload additionally must:
- Require a lockdown administrator with recent password confirmation.
- Use optimistic revision checking so it cannot overwrite an intervening edit.
- Ignore the entire legacy `admins` collection and leave all current accounts unchanged.
- Preserve stored secrets omitted from the file, replace supplied secrets, and clear explicitly empty secrets.
- Commit through the normal revision path and immediately reload affected services.
The browser uploads the selected contents directly. The server never scans the host for a file, and it does not retain, watch, remove, or reuse the uploaded YAML after the database transaction completes.
## 4. Add first-run setup
The server must boot safely with an empty data directory and without any YAML file.
Required flow:
1. Initialize the databases and safe default configuration.
2. Keep all optional external integrations disabled.
3. Generate a one-time setup code in `data/setup-code.txt` with owner-only permissions. Logs report the file location but never the credential.
4. Serve a restricted `/setup` application.
5. Require the setup code before creating the first lockdown administrator.
6. Offer manual YAML configuration-file upload as an alternative to creating the first administrator from scratch.
7. Otherwise collect only the minimum information needed to establish the instance.
8. Permanently disable setup after the first lockdown administrator exists.
A recovery command must be available for resetting or creating a lockdown administrator from the server console. Environment variables must not act as a recurring authentication bypass on every boot.
## 5. Build the centralized admin application
Create a dedicated `/admin` route instead of continuing to expand the existing driver-page admin panel.
The application should provide these top-level destinations:
- Overview and service health
- Fleet and rover operations
- Users, administrators, verification, and permissions
- Configuration, presented as one hierarchical page
Overview may include application logs, persistent audit history, configuration revisions, backup and restore, and system restart or later container-update state. These operational views do not divide the configuration document into categories.
Existing components and server operations should be moved or reused rather than duplicated. The identity database page and other isolated administrative pages should become destinations within this centralized application where doing so preserves their existing behavior.
Authorization rules:
- Normal administrators may perform routine fleet operations.
- Lockdown administrators manage accounts, secrets, server configuration, backup restoration, and other destructive operations.
- Sensitive changes require recent password confirmation.
- Server-side authorization remains authoritative for every operation; hiding a control in React is not an access check.
Configuration uses one schema-generated typed form rather than a raw YAML or JSON text editor. Repeatable values such as cameras, entities, links, and buttons receive the form library's generic add, remove, and reorder workflow.
## 6. Standardize application restart
Replace the current host reboot operation with one deployment-neutral **Restart application** operation.
The restart operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Reject a second request while one is already pending.
3. Persist an audit event, acknowledge the requester, and notify connected browsers.
4. Stop accepting new HTTP connections and send SIGTERM to the Node process after a short acknowledgement delay.
5. Reuse the cleanup hooks already owned by MediaMTX, ffmpeg, Kinect, Balance Board, and other child-process services.
6. Exit normally and rely on the process supervisor to start the application again.
During Phase 1, systemd uses `Restart=always`. During Phase 2, the container uses a restart policy such as `unless-stopped`. An explicit operator `systemctl stop` or container stop remains stopped; only a process exit is restarted. The browser shows the announced reconnect state and reloads the active administration snapshot after Socket.IO reconnects.
Host rebooting is a separate privilege and is not part of this application contract. The server never invokes `systemctl reboot`.
## 7. Implement complete backup and restore
Everything durable living under one data directory makes the backup boundary simple. Backup and restore remain together under one `backupRestoreService`; there is no generic maintenance framework and no command-line workflow.
### Full backup
The primary admin action is **Download full backup**. A full backup includes the entire durable data payload:
- Configuration and secrets
- Administrator accounts
- Identity and permissions
- Fleet history
- Persistent service state
- Snapshots and replay media
- Generated and cached files that are part of the current server state
- A manifest describing the application and schema versions
The backup operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Leave every service and writer running.
3. Create consistent SQLite snapshots using SQLite's online backup support rather than copying active WAL files.
4. Copy non-database durable files and verify their size and modification time before and after each copy, retrying a file that changed during the copy.
5. Exclude `runtime/`, backup/restore staging, SQLite WAL/SHM files, and incomplete files that never become stable during bounded retries.
6. Produce a manifest containing creation time, application version, schema versions, included paths, sizes, and checksums.
7. Stream the completed archive to the authorized browser and remove temporary staging afterward.
The downloaded archive contains credentials and integration secrets. The UI must say so clearly. It must not be exposed through a permanent public URL or retained indefinitely inside the data directory.
`runtime/` is inside the filesystem boundary but is not durable backup content. Audio FIFOs, incomplete uploads, and in-progress replay builds have no restore value and are excluded without stopping their owners. The initial implementation provides only the authoritative full backup.
### Restore
Restore cannot safely overwrite databases underneath running services. It must be a staged, restart-bound operation.
The restore operation must:
1. Require a lockdown administrator and recent password confirmation.
2. Upload the archive into bounded staging controlled by the data directory.
3. Enforce an upload-size limit that is appropriate for full media-inclusive backups.
4. Reject absolute paths, `..` traversal, symlinks, device files, and unexpected archive structures.
5. Validate the manifest and every checksum before altering active data.
6. Check that the backup version has a supported forward migration path.
7. Display exactly what will be replaced.
8. Require a final explicit confirmation.
9. Record a pending-restore marker.
10. Request the normal application restart.
11. Apply the restore before ordinary services open their databases on the next start.
12. Run database migrations against the restored data when necessary.
13. Start the application and verify its health.
The startup restore path must preserve a local rollback snapshot until the restored server passes validation. If extraction, migration, or startup validation fails, it must put the prior data back and report the failure. Restore coordination files may live under `data/system/restore`, but they must be excluded from the restored payload where necessary to avoid recursively restoring an in-progress operation.
Restoring configuration also restores administrator accounts and secrets. The initiating browser may therefore lose authentication after restart; the reconnect UI must explain this and return to login normally. Backup and restore exist only in the protected admin application.
## 8. Internalize MediaMTX WHEP signaling
The current external proxy maps public `/video/<path>` requests to MediaMTX after stripping `/video`. Node should own that mapping directly.
The final request path is:
```text
Browser: /video/<stream>/whep
Node: strips /video and streams the request internally
MediaMTX: /<stream>/whep on 127.0.0.1:8889
```
Required work:
- Add a maintained HTTP proxy library rather than manually reproducing proxy semantics.
- Register the media proxy early enough that request bodies remain unmodified.
- Stream request and response bodies without buffering.
- Forward `POST`, `PATCH`, `DELETE`, authorization, content type, forwarded protocol, and relevant WHEP response headers.
- Apply bounded but media-appropriate proxy timeouts.
- Bind MediaMTX's WHEP listener to loopback.
- Generate browser WHEP URLs relative to the current site origin.
- Remove `media.whepBaseUrl` from configuration.
- Keep public and LAN ICE candidate hostnames as validated admin configuration.
- Test the exact `/video` prefix removal.
- Confirm that MediaMTX's internal HTTP authorization callback still reaches Node.
The actual WebRTC media does not pass through this HTTP proxy. MediaMTX's ICE TCP/UDP port must remain reachable by browsers.
Afterward, the public TLS proxy sends all paths for the site to Node and no longer needs a separate MediaMTX `/video` upstream.
## 9. Phase 1 verification and completion gate
Phase 1 is complete only when all of the following are true:
- The current systemd installation runs without `config.yaml`.
- A completely empty data directory can be initialized through `/setup`.
- An explicitly selected YAML file can initialize the empty database exactly once.
- The setup upload ignores nonexistent fields and reports invalid values supplied for current fields.
- Startup and installation do not search for or modify an old `config.yaml`.
- All mutable server state is contained by the configured data directory.
- A complete backup can be downloaded and validated.
- A restore replaces the server state only after validation and survives restart.
- Failed restore validation leaves the current server unchanged.
- Configuration, administrator accounts, and secrets survive restart.
- The final lockdown administrator cannot be removed accidentally.
- All administrative surfaces are available through `/admin` with server-side authorization.
- Configuration changes create auditable revisions and apply to the running services without an application restart.
- `/video` works through Node without a special public proxy rule for MediaMTX.
- Rover sockets, RTSP publishing, WHEP playback, snapshots, replays, PTZ, Discord, Home Assistant, Kinect, Balance Board, and reporting retain their intended behavior when enabled.
### Filesystem boundary implementation notes
Implemented on 2026-09-13:
- Removed the obsolete `server/src/data` fallback so there is one default data root.
- Added a shared rover-snapshot directory resolver beneath `SERVER_DATA_DIR`.
- Converted rover snapshot polling, PTZ snapshot reads, and health reporting to that resolver.
- Made the MediaMTX supervisor pass its resolved `SERVER_DATA_DIR` to runOnReady hooks.
- Converted the snapshot writer to require that data root and write to `rover-snapshots` beneath it.
- Removed the separate snapshot and replay-segment locations from the systemd unit generated by the installer.
- Made the installer create and own the canonical data and snapshot directories.
- Preserved the existing flat data layout; established SQLite, JSON, replay, cache, and generated MediaMTX paths were already within the boundary.
- Moved audio-forward FIFOs/uploads and both replay-rendering workspaces from the host temporary directory to `data/runtime`.
- Removed the configurable audio-forward runtime path so configuration cannot direct application writes outside `SERVER_DATA_DIR`.
- Kept prompts, public assets, helper binaries, native workers, and TTS assets with the packaged application because they are read-only application material.
- Left any old `/var/lib/rover-snapshots` and `/var/lib/replay-segments` directories untouched but reported during installation. Nothing reads or writes them after the upgraded service starts, and the operator can remove them after verifying the new paths on the actual server.
Local verification completed:
- Data-path helper tests passed for the default root and an overridden temporary root.
- MediaMTX supervisor testing confirmed that the resolved root reaches child hooks.
- The snapshot writer created `rover-snapshots` beneath a temporary data root and failed closed when no data root was supplied.
- All 91 server tests passed, including the new data-path and MediaMTX supervisor coverage.
- Installer and snapshot-writer shell syntax checks passed.
- Source inventory found no remaining application runtime use of the operating system temporary directory, the old snapshot/replay environment variables, or `/var/lib` paths; only tests use OS temporary directories and the installer retains a deliberate legacy-directory notice.
- Real snapshot generation and legacy-directory cleanup still require verification on the actual server during deployment.
### Configuration and administration implementation notes
Implemented on 2026-09-14:
- Added one ordered, strictly validated hierarchical configuration assembled from side-effect-free definitions owned by the services that consume each value.
- Added immutable SQLite configuration revisions, active-revision tracking, administrator accounts, schema migrations, and persistent administrative audit events under the shared data directory.
- Added full-document saves with optimistic revision checking. A stale browser cannot overwrite a newer revision, and invalid or unknown fields cannot become active.
- Redacted secrets from browser responses and audit data. The one complete save operation preserves stored secrets unless the administrator explicitly replaces or clears them.
- Converted every runtime configuration consumer to the synchronous database-backed configuration service and removed the YAML loader, `SERVER_CONFIG`, and the tracked example YAML.
- Added an explicit one-time YAML upload to `/setup`. Existing bcrypt hashes, lockdown roles, Discord identities, configuration, and secrets can be imported only when the operator selects the file; the installer and startup perform no automatic discovery or migration, and there is no command-line importer.
- Added the same explicit legacy YAML picker to the protected Configuration page for replacing an initialized server's configuration. It requires recent lockdown-password confirmation, ignores every YAML administrator entry, filters nonexistent settings, validates current fields, preserves omitted secrets, applies explicitly supplied or empty secrets, uses optimistic revision checking, records the selected filename in audit history, and reloads affected services immediately.
- The one-time setup upload now passes its committed configuration through the same live-application coordinator, so a fresh installation does not need an immediate restart after importing YAML.
- Made setup-file import recursively retain only fields present in the current schema. Stale keys from the permissive YAML era are ignored without aliases or historical translations, while invalid values for real current settings still fail validation; stream-only and snapshot-only room-camera entries remain accepted as they were by the runtime.
- Added safe empty-data startup, a file-backed one-time setup code, the restricted `/setup` route, and a console administrator-recovery command. The credential persists at `data/setup-code.txt` across restarts with `0600` permissions, never appears in logs, and is deleted when setup completes.
- Added the centralized `/admin` route with Overview, Fleet operations, Users and administrators, and one schema-generated hierarchical Configuration page in legacy YAML order.
- Replaced every feature-specific configuration form with `@rjsf/core`; the protected admin snapshot supplies the server's assembled schema, and one generic widget handles all schema-declared secrets.
- Replaced RJSF's unthemed Bootstrap markup with a generic MultiRover tree renderer. The complete document now follows schema order as indented object, array, item, and key/value rows; array controls remain readable text beside each item, and the route-specific styling lives outside the global stylesheet.
- Replaced the editor's custom section borders, header backgrounds, and indentation guides with the application's shared `CardFrame` at every object, array, and array-item layer. Scalar settings remain compact key/value rows, descriptions use the wider value column, and collection actions stay beside their content instead of moving to the far edge.
- Disabled RJSF's internal checkbox label and description generically, leaving the shared field row as the single owner of each boolean setting's name, required marker, and description.
- Restored the former example YAML's installation-specific values as both schema-owned input examples and the actual initial values for non-secret settings and collection shapes. The only intentionally empty defaults are the three credentials and active driver HTML; their placeholders still explain the expected input without falsely marking credentials as configured or publishing sample content.
- Strengthened top-level hierarchy with a 1.5-rem sibling gap while retaining compact spacing within each configuration section.
- Extended `CardFrame` with an optional explicit accent while preserving its assigned-rover default, then gave every configuration nesting level its own complete header-and-border accent. Nested CardFrames themselves now carry the YAML-like indentation, scalar contents remain aligned with their owning card, and descriptions use a larger, higher-contrast treatment.
- Added an opt-in sticky-header behavior to the shared `CardFrame`. Top-level configuration titles use it beneath the independently sticky action toolbar while nested titles remain in normal flow to prevent overlap, and scalar key labels are now visually stronger than their descriptions.
- Traced all 156 schema nodes to their runtime consumers and added operator-facing descriptions for every root, section, collection, array item, and scalar option. A recursive configuration test now rejects any future schema node without a description; currently reserved settings explicitly state that they have no runtime effect.
- Converged feature control into service-owned configuration: each public feature opts in beside its own schema, and the configuration system derives those exact `enabled` switches for sessions and command discovery. The former server feature registry was removed; configuration completeness and hardware availability remain visible as runtime status instead of becoming hidden enablement rules.
- Lazy-loaded setup and administration so the schema-form dependency is not included in ordinary driver-page downloads.
- Reused the existing fleet and identity administration surfaces, added password reconfirmation for sensitive operations, and prevented removal or demotion of the final lockdown administrator.
- Added configuration revision history, rollback, audit history, and immediate application reporting.
- Added a serialized live-configuration coordinator and converted configurable service runtimes to apply changed sections without restarting Node. Passive policies read the current immutable snapshot; network, hardware, timer, and child-process services replace or retune their owned runtime while stable HTTP/socket handlers continue delegating to it. The admin editor reports any service-specific reload failure after the revision is safely committed.
- Replaced the privileged host-reboot action with one lockdown-only, recently confirmed, audited application restart on the admin Overview. Node announces the restart, stops accepting new HTTP connections, and signals itself after acknowledging the browser; the existing service signal hooks clean up owned child processes, and systemd now restarts clean application exits without making `systemctl stop` ineffective.
- Added one protected backup-and-restore service and admin page. Backups keep the application online, use SQLite's online snapshot API for all three databases, make verified stable copies of the remaining durable files, and produce a checksummed archive through a short-lived one-use download. Restore uploads are size-limited, reject unsafe archive entries, verify the complete manifest, checksums, SQLite integrity, and supported schema versions, then remain staged until explicit recent-password confirmation.
- Restore now uses the normal application restart rather than stopping services itself. The earliest server startup swaps the validated replacement into the data directory, retains one rollback copy, and removes that copy only after the restored application reaches a stabilization point; an interrupted or failed first startup automatically puts the previous data back on the following start. Backup/restore control files and all staging remain inside `data/backup-restore`.
- Fixed production WAL-mode snapshots creating unmanifested SQLite `-wal` and `-shm` files during schema inspection. Backup and restore validation now remove only those temporary staged sidecars before archiving or applying data, and the regression fixture uses WAL mode to match the real databases.
- Added the early streaming `/video` middleware with `http-proxy-middleware`. Express removes the public prefix before forwarding WHEP/WHIP requests to `127.0.0.1:8889`, while root-relative MediaMTX session locations receive the prefix again so subsequent browser `PATCH` and `DELETE` requests follow the same path. MediaMTX signaling now binds to loopback; its ICE UDP/TCP listener remains directly reachable on port 8189.
- Replaced Discord's `siteUrl`, the inter-instance profile's `publicUrl`, and media `whepBaseUrl` with one top-level `publicUrl`. A numbered internal database migration transforms every saved configuration revision before current validation, and the media section now contains only optional additional ICE hosts. WHEP and microphone WHIP URLs are fixed relative paths, so they work through the current origin without knowing its hostname.
- Discord command authorization and lockdown moderation recipients now read the live administrator registry, so setup imports and later Discord-ID or role edits take effect without restarting the server.
- Full-data restore now leaves `runtime/` untouched, matching its existing exclusion from backup archives and preventing the non-root application from trying to remove lifecycle-controller state owned by the root controller container.
- The Users and administrators tab now requests at most 100 lightweight identity summaries through one bounded SQLite query. Search and moderation filters run on the server, while complete signals, permissions, and feature state load only after selecting a user, preventing large identity databases from blocking Socket.IO heartbeats or freezing the browser.
- Removed the remaining server-local SRT hops after Fedora's newer libSRT rejected the zero-payload ACKACK packets emitted by MediaMTX's GoSRT implementation on every acknowledgement cycle. PTZ publishing, replay capture, and snapshot capture now share the existing RTSP/TCP listener, SRT is disabled, and browser-session authorization is bypassed only for loopback readers and rover `-fwd` speaker feeds.
- Fixed inter-instance public payload generation to read feature flags and social links from the same live configuration revision. Social links enabled through the new configuration system no longer trigger an undefined legacy-config reference and an HTTP 500 response.
Local verification completed:
- All 119 server tests passed, including populated legacy-style default coverage, complete schema-description and input-example coverage, file-backed setup-code lifecycle and symlink rejection, service-definition-derived feature projection, schema-derived secret paths, configuration defaults and strict validation, full-document revision conflicts, secret preservation, administrator invariants, setup and initialized-server YAML import safety, recursive removal of nonexistent fields, inter-instance payload generation with social links enabled, and the earlier filesystem coverage.
- All 27 server test files passed after live application, backup/restore, the internal media proxy, and the inter-instance regression coverage were added. The media tests stream exact SDP and trickle-ICE bodies through `POST`, `PATCH`, and `DELETE`, preserve headers, verify prefix and session-location rewriting, confirm loopback-only signaling, and derive the public ICE hostname from the canonical URL. The database migration and production-style WAL backup/restore paths are also covered. Application restart was not signaled on the development machine.
- Focused admin, route, and identity UI lint passed.
- All 20 existing focused web UI tests passed.
- The production web UI build completed successfully and regenerated the checked-in server assets.
- Installer syntax and repository whitespace checks passed.
- A local startup smoke test reached listener initialization. MediaMTX then exited because `/usr/local/bin/mediamtx` is intentionally absent on this development machine; actual enabled integrations and media remain deployment checks for the real server.
- A second empty-data startup smoke test loaded every reloadable service and reached the HTTP listener without listener-limit warnings. A deliberately substituted failing MediaMTX executable then ended the process as expected; enabled hardware and external integrations still require verification on the actual server.
Testing-server verification completed:
- A full backup created from the running application successfully validated and restored through the admin UI after the WAL-sidecar fix.
- WHEP video playback works when the testing server is published through an ordinary whole-application reverse proxy. No special `/video` upstream, prefix rewrite, buffering rule, or direct public MediaMTX signaling route is present, confirming that Node now owns the complete public signaling path.
# Phase 2: containerization and image delivery
Phase 2 packages the completed Phase 1 application. It must not introduce a second configuration source or a second persistent-data layout.
## 10. Build the production application image
Use a Fedora-based multi-stage build to remain close to the dependencies already installed by the server installer and to avoid Alpine/musl compatibility problems with native modules and the ChromeOS TTS library.
### Web UI build stage
- Install locked web UI dependencies with `npm ci`.
- Run the production Vite build.
- Copy only the built assets into the final server tree.
### Server dependency stage
- Install locked server dependencies with `npm ci --omit=dev`.
- Supply compiler tooling only in the build stage for native Node modules.
- Copy production dependencies into the final image.
### Native worker stage
- Build the Kinect worker against libfreenect/libusb.
- Build the Balance Board worker against wiiuse/BlueZ.
- Build for `linux/amd64` rather than copying checked-in workstation binaries.
### Packaged runtime tools
At image-build time:
- Download pinned MediaMTX and neolink releases.
- Verify checksums.
- Install ffmpeg, ffprobe, TTS engines, required GStreamer libraries, and runtime native libraries.
- Install the ChromeOS TTS library and voice data.
- Install the TTS and snapshot helper scripts.
- Run reasonable build-time smoke checks.
The actual server must never download or compile these dependencies during container startup.
### Final image
The final image should:
- Contain no compiler toolchain, Git checkout, development dependencies, or build cache.
- Run Node as a dedicated non-root user.
- Use a minimal init process to reap child processes.
- Treat `/data` as its only persistent writable location.
- Use `/tmp` only for disposable work.
- Include ordinary OCI source metadata linking the image to this repository, without introducing an application version number.
- Handle `SIGTERM` through the Phase 1 graceful shutdown coordinator.
### Production image implementation notes
Implemented and locally verified on 2026-09-15:
- Added one root multi-stage `Dockerfile` that builds the Vite application, locked production Node dependencies, Kinect worker, and Balance Board worker, then copies only their runtime outputs into a Fedora 43 image.
- Downloaded pinned amd64 MediaMTX 1.15.3, Neolink 0.6.2, and ChromeOS Google TTS 26.5 artifacts during the build and rejected downloads that did not match their recorded SHA-256 checksums.
- Installed the media, TTS, USB, Bluetooth, and native-worker runtime libraries without Fedora weak dependencies. This avoids pulling unrelated desktop recommendations into the headless image while retaining the libraries explicitly required by the current server installer.
- Added a root `.dockerignore` so local dependencies, mutable server data, generated public assets, compiled host workers, logs, and Git metadata cannot leak into the image build context.
- Configured `/data` as `SERVER_DATA_DIR`, ran Node as the dedicated uid 1000 `multirover` user, retained only the Balance Board worker's required capabilities, and used `tini` as the container init process.
- Successfully built and loaded `multiroombarover:local` for `linux/amd64`. Its registry-style compressed content size is approximately 828 MB; Docker reports approximately 2.87 GB of local unpacked disk usage because the complete GStreamer, ffmpeg, Kinect, Node, and offline TTS runtime is intentionally included.
- Confirmed at build time that Chrome TTS loads its packaged voice model and produces a nonempty WAV file.
- Replaced Fedora's restricted `ffmpeg-free` package with RPM Fusion Free's complete `ffmpeg` package after development-container testing exposed that `ffmpeg-free` omits the `libx264` encoder required by rover replay capture, room-camera replay rendering, replay sidebars, and final replay assembly.
- Started the image with host networking and a temporary SELinux-relabeled `/data` bind mount. The application reached its HTTP listener, generated first-run state only inside the mount, and started the packaged MediaMTX with its generated configuration under `/data`.
- Confirmed `/`, `/setup`, and `/admin` return the production UI; `/video/` reaches the loopback MediaMTX proxy; MediaMTX and Neolink execute; both native workers link against the runtime image; and the Balance Board worker retains only `cap_net_admin` and `cap_net_bind_service`.
- Restarted the same container and confirmed the setup credential and configuration database were byte-for-byte unchanged, then confirmed `/admin` returned successfully again.
- Stopped and removed the smoke-test container and deleted its temporary data. No test server process was left running on the development machine.
## 11. Compose deployment
The host-visible project installation should be only:
```text
multirover/
└── compose.yaml
```
Docker owns the separately persisted `multirover-data` volume. Operators move
or inspect its complete contents through the administration backup/restore UI
rather than coordinating host filesystem ownership with the container user.
The Compose project contains:
- The main Multirover application container
- A small lifecycle container used for application update and restart
The application mounts:
```text
data:/data
```
Host networking is the initial preferred design because it most closely preserves current rover RTSP, WebRTC ICE, UDP media, camera, and LAN integration behavior. The exact listeners must be audited before finalizing the Compose file.
Expected externally relevant listeners are:
- Node HTTP, Socket.IO, and proxied WHEP signaling on TCP 8080
- Rover RTSP publishing on TCP 8554
- WebRTC media on TCP and UDP 8189
MediaMTX WHEP on 8889 and API/metrics listeners should stay on loopback unless an identified remote consumer requires otherwise. Publishers and server-local replay/snapshot readers use the single RTSP/TCP listener on 8554; SRT is disabled.
### Compose implementation notes
Implemented and locally verified on 2026-09-15:
- Added one root `compose.yaml` containing only the main application. It uses `ghcr.io/legop3/multiroombarover:latest`, host networking, `restart: unless-stopped`, and the single `data:/data` persistent named-volume mount. The lifecycle service remains a later, separate step rather than a placeholder in the initial deployment.
- Added both possible local data directories to `.dockerignore`, alongside the legacy `server/data`, so credentials, databases, recordings, backups, and generated state cannot enter later image builds even during development or manual inspection.
- Started the exact Compose definition from an empty Docker-managed volume using the locally built image tagged with the final GHCR name. The application created its configuration database, setup credential, and generated MediaMTX configuration only under that volume.
- Confirmed the production UI responds on `/`, `/setup`, and `/admin`. A request to `/video/` reached the internal MediaMTX proxy and received MediaMTX's expected not-found response because the empty configuration had no requested stream.
- Restarted through Compose and confirmed the setup credential and configuration database remained byte-for-byte unchanged. A separate marker created through `/data` also remained present after restart.
## 12. Hardware access with minimal host setup
The host must still provide the kernel and system services that containers cannot safely configure for themselves.
Kinect requirements:
- One host udev rule granting the intended device access
- The necessary USB device mount, likely `/dev/bus/usb` because Kinect device numbering can change
- libfreenect and the worker inside the image
Balance Board requirements:
- Host Bluetooth daemon configuration required by the existing raw HID design
- Access to the host BlueZ D-Bus socket
- Only the network capabilities required by the native worker
- No fully privileged main application container
The exact capabilities and device permissions must be proven on the real server hardware. This development machine is not the actual server and cannot complete that validation.
### Hardware-access implementation notes
Implemented and locally verified as far as this development host permits on 2026-09-15:
- Added the BlueZ command-line package to the runtime image because the Balance Board service commissions devices through `bluetoothctl`; the rebuilt image reports BlueZ 5.87.
- Exposed `/dev/bus/usb` so reconnecting Kinect devices do not depend on a temporary bus/device number, and granted only `NET_ADMIN` for the Balance Board worker rather than using privileged mode.
- Mounted only the host system D-Bus socket for BlueZ access. Docker's per-container SELinux label is disabled because Fedora blocks access to the shared host socket and USB device nodes otherwise, while relabeling the system socket would affect the host; the process remains non-root and Docker's namespace, capability, and seccomp isolation remain active.
- Confirmed the container can open the mounted system D-Bus socket and that its native Balance Board worker retains only its existing file capabilities. The development host's Bluetooth daemon is inactive and no production Kinect or Balance Board is attached, so real discovery, reconnect, and streaming remain part of the actual-server validation.
The host should not need Node, npm, MediaMTX, ffmpeg, neolink, application source, or a Multirover systemd unit after cutover.
## 13. Health checks
Add an internal health endpoint that verifies:
- Node is accepting requests.
- Configuration initialization and migrations succeeded.
- The data directory is readable and writable.
- Required SQLite databases are usable.
- MediaMTX is running and its internal endpoint responds.
Optional remote integrations should report degraded status to administrators without forcing a container restart loop. Home Assistant, Discord, a camera, or an LLM server being offline does not mean the application process itself is unhealthy.
Compose should use the health endpoint and a restart policy suitable for unattended operation.
### Health-check implementation notes
Implemented on 2026-09-15:
- Added an unauthenticated `GET /health` readiness endpoint that exposes only two non-sensitive booleans: whether the application user can read and write the configured data directory and whether MediaMTX answers through its loopback-only metrics listener.
- Treated successful route execution as proof that Node is accepting HTTP and that configuration initialization completed. This avoids repeatedly querying every SQLite database or turning optional integrations and currently offline media sources into container restart conditions.
- Added the image-level Docker health check using Node's built-in `fetch`, so Compose receives the readiness state without installing another command-line probe utility.
## 14. Restricted lifecycle container
The main web application must not mount the Docker socket. Docker socket access is effectively host-root access.
A small lifecycle container should be the only component with Docker control. It should:
- Have no public network port.
- Accept requests only through a shared Unix socket under `data/system` or another private Compose-only channel.
- Operate only on the fixed Multirover application service.
- Reject arbitrary command lines, service names, image names, and Compose arguments.
- Persist update job state so it survives replacement of the application container.
- Compare the running image's internal digest with the current `latest` digest.
- Pull the fixed `ghcr.io/legop3/multiroombarover:latest` image.
- Restart or recreate the application container.
- Wait for the application health check.
- Retain and restore the previous image when the replacement fails.
The `/admin` System section should expose:
- Whether the running application is current or an update is available
- Check for update
- Update and restart
- Restart application
- Update progress and recent output
- Last update result
- Rollback result
These operations require a lockdown administrator and recent password confirmation. The browser must expect its socket to disappear, show a reconnect state, and retrieve the persistent job result after the new application becomes healthy.
The Compose contract should remain stable so ordinary main-branch image updates replace only the application image. Updating the lifecycle component or changing host mounts/capabilities is a separate, rarer deployment-format update and must not be disguised as an ordinary application update.
### Lifecycle-controller implementation notes
Implemented on 2026-09-15:
- Reused the single published application image for the lifecycle service with a controller-only command. This avoids a second Dockerfile, image name, GHCR workflow, and release lifecycle while the two containers still run separate processes with separate privileges.
- Mounted `/var/run/docker.sock` only in the network-disabled lifecycle container. The application communicates through a dedicated Unix-socket volume and cannot submit an image name, container name, command, or Docker option; the controller operates only on the fixed `multirover` container and the deployment-selected MultiRover image.
- Added fixed status, update-check, restart, and update operations. Update checks pull the configured moving image and compare Docker image IDs. Updates retain the previous image ID, recreate the application with its existing Compose host contract, wait for the image health check, and restore the previous image when replacement health fails.
- Persisted the current operation and result in the private lifecycle Unix-socket volume. This survives ordinary application replacement and browser reconnection without mounting the root lifecycle controller into the application's `/data` volume, leaving all application runtime paths owned by the non-root server.
- Connected the existing lockdown-administrator password confirmation and audit history to the lifecycle operations. The Administration overview polls persisted progress, reports update and rollback results, and keeps the legacy process-level restart only when no controller socket exists.
- Defined the deployment image once through a Compose YAML anchor. Both services and the controller target reuse that exact value, so production stays on `ghcr.io/legop3/multiroombarover:latest` and development requires changing only the single visible selector line to a branch tag.
## 15. GHCR publishing automation
Add repository automation that:
- On pull requests, runs all required verification and proves that the production image builds without publishing it.
- On each repository branch push, builds the production image from a clean checkout.
- Uses the production Dockerfile as the single verification path. Its locked dependency installs, web UI production build, native worker builds, external-artifact checksum checks, and TTS smoke test must all pass before publication.
- Builds the supported `linux/amd64` image without QEMU or a multi-architecture manifest.
- Publishes `ghcr.io/legop3/multiroombarover:latest` from main and one sanitized branch-name tag from every other repository branch; there are no numbered, commit, stable, edge, or release tags.
- Leaves the previously published image for that branch untouched when any required verification or build step fails.
- Uses registry-generated digests only inside the lifecycle implementation for update comparison and rollback.
The deployed server pulls the prebuilt `latest` image. It does not run `git pull`, `npm install`, native compilation, or web UI compilation.
### GHCR automation implementation notes
Implemented on 2026-09-15:
- Added one `Container image` GitHub Actions workflow. Pull requests build the complete production Dockerfile without logging in or publishing. Main-branch pushes publish `ghcr.io/legop3/multiroombarover:latest`, while every other repository branch publishes one moving image using its sanitized branch name.
- Used GitHub's repository-scoped token with only contents-read and packages-write permissions. No separate registry secret, release process, version calculation, QEMU setup, or custom tag-generation code is required.
- Kept one Buildx job for all event types so pull-request verification, development branches, and main-branch publication cannot drift into different image recipes. Docker's maintained metadata action owns branch-name sanitization, and GitHub Actions layer caching avoids repeatedly downloading and rebuilding the image's large pinned media and TTS dependencies.
- Removed the legacy package-lock ignore rules and added the server lockfile required by `npm ci` to the migration change set. Local Docker builds and clean GitHub checkouts now receive the same locked server and web UI dependency inputs instead of allowing an ignored workstation file to mask a missing build input.
- The workflow file was parsed locally and its event, permission, architecture, tag-selection, and conditional-publish contract were checked. The first actual GHCR publication necessarily remains a GitHub-hosted verification after these changes are pushed.
## 16. Container cutover
The actual deployment migration should:
1. Download and validate a full Phase 1 backup.
2. Stop and disable the legacy Multirover systemd service.
3. Ensure no legacy MediaMTX service remains active.
4. Place the Compose file on the host; Docker creates the named data volume on first start.
5. Start the application and lifecycle containers.
6. Confirm that database migrations complete.
7. Confirm the active configuration revision and administrator access.
8. Verify rover connectivity, media publishing, WHEP playback, replay, cameras, and enabled hardware/integrations.
9. Exercise application restart through `/admin`.
10. Exercise an image update and health-check result.
11. Retain the Phase 1 backup until the container deployment has been accepted.
The old systemd application and the Compose application must never run concurrently because they would compete for HTTP and media ports.
## 17. Phase 2 completion gate
Containerization is complete when:
- A new host can start from one Compose file and an automatically created empty data volume.
- Existing state can be restored from a Phase 1 full backup.
- `data:/data` is the only persistent application mount.
- Replacing the application container preserves all state.
- The special external `/video` MediaMTX route is unnecessary.
- The main container has no Docker socket access and is not fully privileged.
- Admin-triggered restart works.
- Admin-triggered update works and persists progress across reconnection.
- A failed image health check rolls back to the prior image.
- The GHCR `latest` image is reproducibly built from the newest successful main-branch commit.
- Kinect and Balance Board behavior has been verified on the actual host.
- Node, npm, application source, and media binaries are no longer installed directly on the host.
# Recommended implementation order
Within the two hard phase boundaries, the safest order is:
- [x] Complete the filesystem audit and single data-directory migration.
- [x] Add the configuration schema/database and administrator storage.
- [x] Add first-run setup and explicit YAML configuration-file upload.
- [x] Convert every configuration consumer and remove YAML runtime loading.
- [x] Converge optional feature control into service-owned `enabled` switches and derive the public feature map from those definitions.
- [x] Build the centralized admin configuration UI.
- [x] Add persistent audit history.
- [x] Apply every configuration revision to running services without restarting the application.
- [x] Standardize graceful application restart.
- [x] Implement online backup and restart-bound staged restore in one service.
- [x] Add the internal `/video` proxy and make the special external route unnecessary.
- [x] Run the full Phase 1 completion gate on the legacy deployment.
- [x] Build and verify the production application image.
- [x] Add Compose, data mounting, networking, and hardware access.
- [x] Add GHCR build and publication automation.
- [x] Add the restricted lifecycle container and connect the System UI.
- [ ] Test update, rollback, backup restore, and hardware on the actual server.
- [ ] Perform the final systemd-to-Compose cutover.
This order gives each invasive change one clear source of failures and leaves the container phase responsible for packaging and supervision rather than unfinished application architecture.
+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.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Daniel Roberts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,218 @@
# RoverPeripheral
RoverPeripheral is an ESP32 Arduino library for MultiRoombaRover peripherals.
The ESP32 reports its built-in rover roles and accessory controls to `roverd`
over USB serial.
## PlatformIO installation
Classic ESP32 DevKitC-style board:
```ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
Native-USB ESP32-S3 DevKitC:
```ini
[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
legop3/RoverPeripheral @ ^2.0.0
```
## Program structure
Include `RoverPeripheral.h` and define `configureRoverPeripheral()`:
```cpp
#include <RoverPeripheral.h>
void configureRoverPeripheral(RoverPeripheral& peripheral) {
peripheral.name("Headlight controller");
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
}
```
The library provides `setup()` and `loop()`. Do not define them in the
peripheral program.
## Built-in rover roles
Camera tilt:
```cpp
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
peripheral.addCameraServo(cameraServo);
```
Headlight or laser:
```cpp
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
peripheral.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
peripheral.addLaser(laser);
```
These registrations use the existing camera, headlight, and laser controls in
the rover UI. They do not create accessory controls.
## Accessory controls
Controls appear in registration order. Each control name must be unique within
the peripheral. The name is also used as the control identifier.
### Servo slider
```cpp
SliderControlConfig position;
position.name = "Arm position";
position.minimum = 0;
position.maximum = 180;
ServoOutput servo;
servo.pin = 13;
peripheral.addSlider(position, servo);
```
### PWM slider
```cpp
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput light;
light.pin = 17;
peripheral.addSlider(brightness, light);
```
### Digital button
```cpp
ButtonControlConfig workLight;
workLight.name = "Work light";
workLight.mode = ButtonMode::Toggle;
DigitalOutput light;
light.pin = 21;
light.polarity = OutputPolarity::ActiveHigh;
peripheral.addButton(workLight, light);
```
### Custom slider
```cpp
void setMotorSpeed(int value) {
// Apply value to the device.
}
SliderControlConfig speed;
speed.name = "Motor speed";
speed.minimum = 0;
speed.maximum = 100;
peripheral.addSlider(speed, setMotorSpeed);
```
### Custom button
```cpp
void setMotorRunning(bool running) {
// Start or stop the device.
}
ButtonControlConfig motor;
motor.name = "Motor";
motor.mode = ButtonMode::Momentary;
peripheral.addButton(motor, setMotorRunning);
```
A momentary bool callback receives `true` on press and `false` on release. A
zero-argument callback can be used for a one-shot momentary action.
### Number input
```cpp
void setRepeatCount(int value) {
// Store or apply value.
}
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
peripheral.addNumber(repeats, setRepeatCount);
```
### Text input
```cpp
void setDisplayMessage(const String& value) {
// Store or display value.
}
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
peripheral.addText(message, setDisplayMessage);
```
## Recurring work
Define `updateRoverPeripheral()` when the program needs recurring non-blocking
work:
```cpp
void updateRoverPeripheral() {
// Update a state machine or device.
}
```
Callbacks and `updateRoverPeripheral()` must not block serial processing.
`Serial` is reserved for Firmata and must not be used for debug output.
## License
MIT
@@ -0,0 +1,76 @@
#include <RoverPeripheral.h>
namespace {
constexpr uint8_t kActionPin = 21;
int repeatCount = 1;
String displayMessage;
void setActionActive(bool pressed) {
digitalWrite(kActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
repeatCount = value;
}
void setDisplayMessage(const String& value) {
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Complete rover peripheral");
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
io.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
io.addLaser(laser);
pinMode(kActionPin, OUTPUT);
digitalWrite(kActionPin, LOW);
SliderControlConfig brightness;
brightness.name = "Light brightness";
brightness.minimum = 0;
brightness.maximum = 255;
PwmOutput brightnessOutput;
brightnessOutput.pin = 17;
io.addSlider(brightness, brightnessOutput);
ButtonControlConfig action;
action.name = "Special action";
action.mode = ButtonMode::Momentary;
io.addButton(action, setActionActive);
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
io.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
io.addText(message, setDisplayMessage);
}
@@ -0,0 +1,11 @@
#include <RoverPeripheral.h>
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Headlight controller");
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
}
@@ -0,0 +1,26 @@
{
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
"name": "RoverPeripheral",
"version": "2.0.1",
"description": "Create self-describing ESP32 hardware controls for MultiRoombaRover",
"keywords": [
"esp32",
"firmata",
"robotics",
"rover"
],
"repository": {
"type": "git",
"url": "https://github.com/legop3/MultiRoombaRover.git"
},
"homepage": "https://github.com/legop3/MultiRoombaRover/tree/main/esp32/libraries/RoverPeripheralFirmata",
"license": "MIT",
"frameworks": "arduino",
"platforms": "espressif32",
"headers": "RoverPeripheral.h",
"dependencies": {
"ConfigurableFirmata": "https://github.com/firmata/ConfigurableFirmata.git#3.2.0",
"bblanchon/ArduinoJson": "^7.4.2",
"madhephaestus/ESP32Servo": "^3.0.8"
}
}
@@ -0,0 +1,78 @@
#include "RoverPeripheral.h"
#include "internal/RoverPeripheralFirmata.h"
RoverPeripheral::RoverPeripheral()
: implementation_(new RoverPeripheralFirmata("Rover peripheral")) {}
RoverPeripheral::~RoverPeripheral() {
delete implementation_;
}
void RoverPeripheral::name(const String& peripheralName) {
implementation_->setName(peripheralName);
}
void RoverPeripheral::addCameraServo(const RoverCameraServoConfig& config) {
implementation_->addRoverCameraServo(config);
}
void RoverPeripheral::addHeadlight(const RoverDigitalOutputConfig& config) {
implementation_->addRoverHeadlight(config);
}
void RoverPeripheral::addLaser(const RoverDigitalOutputConfig& config) {
implementation_->addRoverLaser(config);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, const ServoOutput& output) {
implementation_->addServoSlider(config, output);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, const PwmOutput& output) {
implementation_->addPwmSlider(config, output);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, const DigitalOutput& output) {
implementation_->addDigitalButton(config, output);
}
void RoverPeripheral::addSlider(const SliderControlConfig& config, SliderCallback callback) {
implementation_->addSlider(config, callback);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
implementation_->addButton(config, callback);
}
void RoverPeripheral::addButton(const ButtonControlConfig& config, ActionCallback callback) {
// One-shot callbacks apply only to momentary buttons. A toggle requires the
// bool callback overload because application code must receive its new state.
if (config.mode != ButtonMode::Momentary) {
abort();
}
implementation_->addButton(
config,
[callback](bool pressed) {
if (pressed && callback) {
callback();
}
}
);
}
void RoverPeripheral::addNumber(const NumberControlConfig& config, NumberCallback callback) {
implementation_->addNumber(config, callback);
}
void RoverPeripheral::addText(const TextControlConfig& config, TextCallback callback) {
implementation_->addText(config, callback);
}
void RoverPeripheral::begin(FirmataExt& extension) {
implementation_->begin(extension);
}
void RoverPeripheral::update() {
implementation_->update();
}
@@ -0,0 +1,156 @@
#pragma once
#include <Arduino.h>
#include <functional>
/** Describes whether a logical on value drives an output pin high or low. */
enum class OutputPolarity {
ActiveHigh,
ActiveLow,
};
/** Selects whether a button retains its state or is active only while held. */
enum class ButtonMode {
Toggle,
Momentary,
};
/** Configuration for the rover's existing camera-tilt control. */
struct RoverCameraServoConfig {
uint8_t pin = 0;
float minimumAngleDegrees = -15;
float maximumAngleDegrees = 30;
float homeAngleDegrees = 0;
float nudgeDegrees = 2;
uint16_t minimumPulseMicroseconds = 900;
uint16_t maximumPulseMicroseconds = 2100;
bool allowRawPulse = false;
bool inverted = false;
};
/** Configuration for the rover's existing headlight or laser control. */
struct RoverDigitalOutputConfig {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
bool initiallyOn = false;
};
/** Shared display and range settings for a slider control. */
struct SliderControlConfig {
String name;
int minimum = 0;
int maximum = 100;
};
/** Shared display and interaction settings for a button control. */
struct ButtonControlConfig {
String name;
ButtonMode mode = ButtonMode::Momentary;
};
/** Shared display and range settings for a number input. */
struct NumberControlConfig {
String name;
int minimum = 0;
int maximum = 100;
};
/** Shared display and length settings for a text input. */
struct TextControlConfig {
String name;
size_t maximumLength = 32;
};
/** Selects a standard Firmata servo as the destination for a slider. */
struct ServoOutput {
uint8_t pin = 0;
};
/** Selects an ESP32 PWM pin as the destination for a slider. */
struct PwmOutput {
uint8_t pin = 0;
};
/** Selects an ESP32 digital pin as the destination for a button. */
struct DigitalOutput {
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
};
using SliderCallback = std::function<void(int)>;
using ButtonCallback = std::function<void(bool)>;
using ActionCallback = std::function<void()>;
using NumberCallback = std::function<void(int)>;
using TextCallback = std::function<void(const String&)>;
class FirmataExt;
class RoverPeripheralFirmata;
/**
* Registration API for a self-describing rover peripheral.
*
* A sketch constructs each configuration one field at a time and registers it
* in configureRoverPeripheral(). Serial and protocol setup stay in the library.
*/
class RoverPeripheral {
public:
RoverPeripheral();
~RoverPeripheral();
RoverPeripheral(const RoverPeripheral&) = delete;
RoverPeripheral& operator=(const RoverPeripheral&) = delete;
/** Sets the peripheral name shown above its accessory controls. */
void name(const String& peripheralName);
/** Registers the rover's existing camera-tilt control. */
void addCameraServo(const RoverCameraServoConfig& config);
/** Registers the rover's existing headlight control. */
void addHeadlight(const RoverDigitalOutputConfig& config);
/** Registers the rover's existing laser control. */
void addLaser(const RoverDigitalOutputConfig& config);
/** Registers a slider backed by a standard Firmata servo output. */
void addSlider(const SliderControlConfig& config, const ServoOutput& output);
/** Registers a slider backed by an ESP32 PWM output. */
void addSlider(const SliderControlConfig& config, const PwmOutput& output);
/** Registers a button backed by an ESP32 digital output. */
void addButton(const ButtonControlConfig& config, const DigitalOutput& output);
/** Registers a slider handled by application code. */
void addSlider(const SliderControlConfig& config, SliderCallback callback);
/** Registers a button whose callback receives its logical state. */
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
/** Registers a momentary button whose callback runs only on press. */
void addButton(const ButtonControlConfig& config, ActionCallback callback);
/** Registers a number input handled by application code. */
void addNumber(const NumberControlConfig& config, NumberCallback callback);
/** Registers a text input handled by application code. */
void addText(const TextControlConfig& config, TextCallback callback);
private:
// The implementation is opaque so importing this header does not expose any
// Firmata types or require firmware authors to understand the wire protocol.
RoverPeripheralFirmata* implementation_;
void begin(FirmataExt& extension);
void update();
friend void setup();
friend void loop();
};
/** Called once by the library after Arduino and Serial initialization. */
void configureRoverPeripheral(RoverPeripheral& peripheral);
/** Optional non-blocking hook for recurring application work. */
void updateRoverPeripheral();
@@ -0,0 +1,46 @@
#include "RoverPeripheral.h"
#include <ConfigurableFirmata.h>
#include <FirmataExt.h>
namespace {
FirmataExt firmataExtension;
RoverPeripheral peripheral;
} // namespace
// A weak no-op preserves the zero-boilerplate case while allowing a sketch to
// define the same function when animations or state machines need regular work.
void __attribute__((weak)) updateRoverPeripheral() {}
void setup() {
// The public configuration hook runs after Arduino initialization, allowing
// peripheral code to safely use pinMode() and initialize third-party devices.
Serial.begin(115200);
configureRoverPeripheral(peripheral);
// ConfigurableFirmata batches reads on ESP32-class boards. Arduino's default
// one-second Stream timeout would delay short commands while waiting for the
// batch buffer to fill, so consume only bytes that have already arrived.
Serial.setTimeout(0);
Firmata.begin(Serial);
peripheral.begin(firmataExtension);
// Applying a normal Firmata reset after registration establishes every
// declared initial output and makes the first host connection deterministic.
Firmata.parse(SYSTEM_RESET);
}
void loop() {
// ConfigurableFirmata retains partial parser state between iterations. Stop
// after each complete message so user update work cannot be starved by a
// sustained burst, while ordinary short commands are still drained at once.
while (Firmata.available()) {
Firmata.processInput();
if (!Firmata.isParsingMessage()) {
break;
}
}
peripheral.update();
updateRoverPeripheral();
}
@@ -0,0 +1,521 @@
#include "RoverPeripheralFirmata.h"
namespace {
constexpr byte kPeripheralFeature = 0x01;
constexpr byte kDescribeOperation = 0x00;
constexpr byte kDescriptionOperation = 0x01;
constexpr byte kControlOperation = 0x02;
const char* buttonModeName(ButtonMode mode) {
return mode == ButtonMode::Toggle ? "toggle" : "momentary";
}
const char* outputTypeName(uint8_t value) {
switch (value) {
case 0:
return "servo";
case 1:
return "pwm";
case 2:
return "digital";
default:
return "custom";
}
}
} // namespace
RoverPeripheralFirmata* RoverPeripheralFirmata::instance_ = nullptr;
RoverPeripheralFirmata::RoverPeripheralFirmata(const String& name) : name_(name) {}
void RoverPeripheralFirmata::setName(const String& name) {
if (name.length() == 0) {
// A blank heading makes multiple attached peripherals impossible to
// distinguish. Treat it as a firmware-authoring error at startup rather
// than advertising ambiguous controls to the rover.
abort();
}
name_ = name;
}
void RoverPeripheralFirmata::validateControlName(const String& name) const {
if (name.length() == 0) {
// Registration errors are programmer errors discovered during setup. A
// hard stop is preferable to advertising a partially usable device whose
// behavior depends on which malformed control the driver touches first.
abort();
}
for (const ControlRegistration& existing : controls_) {
if (existing.id == name) {
abort();
}
}
}
void RoverPeripheralFirmata::validateRange(const String& name, int minimum, int maximum) const {
if (name.length() == 0 || minimum > maximum) {
abort();
}
}
void RoverPeripheralFirmata::addServoSlider(const SliderControlConfig& config, const ServoOutput& output) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Servo;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.pin = output.pin;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addPwmSlider(const SliderControlConfig& config, const PwmOutput& output) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Pwm;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.pin = output.pin;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output) {
validateControlName(config.name);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Button;
control.output = OutputType::Digital;
control.buttonMode = config.mode;
control.pin = output.pin;
control.polarity = output.polarity;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addSlider(const SliderControlConfig& config, SliderCallback callback) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Slider;
control.output = OutputType::Custom;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.sliderCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addButton(const ButtonControlConfig& config, ButtonCallback callback) {
validateControlName(config.name);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Button;
control.output = OutputType::Custom;
control.buttonMode = config.mode;
control.buttonCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addNumber(const NumberControlConfig& config, NumberCallback callback) {
validateControlName(config.name);
validateRange(config.name, config.minimum, config.maximum);
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Number;
control.output = OutputType::Custom;
control.minimum = config.minimum;
control.maximum = config.maximum;
control.numberCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addText(const TextControlConfig& config, TextCallback callback) {
validateControlName(config.name);
if (config.maximumLength == 0) {
abort();
}
ControlRegistration control;
control.id = config.name;
control.name = config.name;
control.type = ControlType::Text;
control.output = OutputType::Custom;
control.maximumLength = config.maximumLength;
control.textCallback = callback;
controls_.push_back(control);
}
void RoverPeripheralFirmata::addRoverCameraServo(const RoverCameraServoConfig& config) {
cameraServo_ = config;
hasCameraServo_ = true;
}
void RoverPeripheralFirmata::addRoverHeadlight(const RoverDigitalOutputConfig& config) {
headlight_ = config;
hasHeadlight_ = true;
}
void RoverPeripheralFirmata::addRoverLaser(const RoverDigitalOutputConfig& config) {
laser_ = config;
hasLaser_ = true;
}
void RoverPeripheralFirmata::begin(FirmataExt& extension) {
if (instance_ != nullptr && instance_ != this) {
abort();
}
instance_ = this;
extension.addFeature(*this);
// Discovery uses Firmata's standard REPORT_FIRMWARE query to distinguish a
// rover peripheral from unrelated Firmata devices. The helper owns this
// identity so every sketch gets it without repeating protocol boilerplate.
Firmata.setFirmwareNameAndVersion("RoverPeripheralFirmata", 1, 0);
// SET_DIGITAL_PIN_VALUE is a fixed Firmata command rather than SysEx, so it
// cannot travel through FirmataFeature::handleSysex. Firmata exposes one
// callback for it and this peripheral owns the standard output implementation.
Firmata.attach(SET_DIGITAL_PIN_VALUE, digitalPinValueCallback);
Firmata.attach(SYSTEM_RESET, systemResetCallback);
}
void RoverPeripheralFirmata::update() {
// Custom callbacks execute synchronously from Firmata's parser for now. This
// method intentionally remains available so future non-blocking peripheral
// work can be serviced without changing the sketch's main loop shape.
}
void RoverPeripheralFirmata::handleCapability(byte pin) {
if (!IS_PIN_DIGITAL(pin)) {
return;
}
// The peripheral supports the output modes roverd may select. Capability
// reporting stays standard Firmata, so the Linux probe can also inspect it
// with any other conforming client.
Firmata.write(PIN_MODE_OUTPUT);
Firmata.write(1);
if (IS_PIN_PWM(pin)) {
Firmata.write(PIN_MODE_PWM);
Firmata.write(DEFAULT_PWM_RESOLUTION);
}
Firmata.write(PIN_MODE_SERVO);
Firmata.write(14);
}
boolean RoverPeripheralFirmata::handlePinMode(byte pin, int mode) {
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
return false;
}
// A pin can only have one active hardware generator. Detaching a previous
// servo before switching modes prevents it from continuing to pulse after a
// later digital or PWM configuration takes ownership of the pin.
if (mode != PIN_MODE_SERVO) {
detachServo(pin);
}
switch (mode) {
case PIN_MODE_OUTPUT:
pinMode(PIN_TO_DIGITAL(pin), OUTPUT);
digitalWrite(PIN_TO_DIGITAL(pin), LOW);
Firmata.setPinState(pin, 0);
return true;
case PIN_MODE_PWM:
if (!IS_PIN_PWM(pin)) {
return false;
}
pinMode(PIN_TO_PWM(pin), OUTPUT);
analogWrite(PIN_TO_PWM(pin), 0);
Firmata.setPinState(pin, 0);
return true;
case PIN_MODE_SERVO:
attachServo(pin);
Firmata.setPinState(pin, 0);
return true;
default:
return false;
}
}
boolean RoverPeripheralFirmata::handleSysex(byte command, byte argc, byte* argv) {
if (command == kPeripheralFeature) {
if (argc == 0) {
return true;
}
if (argv[0] == kDescribeOperation) {
buildAndSendDescription();
} else if (argv[0] == kControlOperation) {
dispatchCustomControl(argc, argv);
}
return true;
}
if (command == SERVO_CONFIG && argc >= 5) {
const byte pin = argv[0];
const int minimumPulse = argv[1] | (argv[2] << 7);
const int maximumPulse = argv[3] | (argv[4] << 7);
if (pin < TOTAL_PINS && IS_PIN_DIGITAL(pin)) {
Firmata.setPinMode(pin, PIN_MODE_SERVO);
attachServo(pin, minimumPulse, maximumPulse);
}
return true;
}
if (command == EXTENDED_ANALOG && argc >= 2) {
const byte pin = argv[0];
if (pin >= TOTAL_PINS) {
return true;
}
int value = 0;
// Extended analog values contain a variable number of seven-bit chunks.
// Reassembling every received chunk keeps servo angles and PWM values fully
// compatible with normal Firmata clients rather than assuming eight bits.
for (byte index = 1; index < argc && index <= 4; ++index) {
value |= static_cast<int>(argv[index]) << (7 * (index - 1));
}
const byte mode = Firmata.getPinMode(pin);
if (mode == PIN_MODE_PWM && IS_PIN_PWM(pin)) {
analogWrite(PIN_TO_PWM(pin), value);
Firmata.setPinState(pin, value);
} else if (mode == PIN_MODE_SERVO && servos_[pin] != nullptr) {
servos_[pin]->write(value);
Firmata.setPinState(pin, value);
}
return true;
}
return false;
}
void RoverPeripheralFirmata::reset() {
for (byte pin = 0; pin < TOTAL_PINS; ++pin) {
detachServo(pin);
}
// Built-in role defaults are applied on Firmata reset as well as boot. This
// makes reconnecting a client deterministic without creating a second state
// model on the ESP32.
if (hasHeadlight_) {
pinMode(headlight_.pin, OUTPUT);
const bool physicalHigh = headlight_.initiallyOn != (headlight_.polarity == OutputPolarity::ActiveLow);
writeDigitalPin(headlight_.pin, physicalHigh);
}
if (hasLaser_) {
pinMode(laser_.pin, OUTPUT);
const bool physicalHigh = laser_.initiallyOn != (laser_.polarity == OutputPolarity::ActiveLow);
writeDigitalPin(laser_.pin, physicalHigh);
}
}
void RoverPeripheralFirmata::buildAndSendDescription() {
JsonDocument document;
document["name"] = name_;
if (hasCameraServo_ || hasHeadlight_ || hasLaser_) {
JsonObject roverControls = document["roverControls"].to<JsonObject>();
if (hasCameraServo_) {
JsonObject servo = roverControls["cameraServo"].to<JsonObject>();
servo["pin"] = cameraServo_.pin;
servo["minimumAngleDegrees"] = cameraServo_.minimumAngleDegrees;
servo["maximumAngleDegrees"] = cameraServo_.maximumAngleDegrees;
servo["homeAngleDegrees"] = cameraServo_.homeAngleDegrees;
servo["nudgeDegrees"] = cameraServo_.nudgeDegrees;
servo["minimumPulseMicroseconds"] = cameraServo_.minimumPulseMicroseconds;
servo["maximumPulseMicroseconds"] = cameraServo_.maximumPulseMicroseconds;
servo["allowRawPulse"] = cameraServo_.allowRawPulse;
servo["inverted"] = cameraServo_.inverted;
}
auto addDigitalRole = [&roverControls](const char* key, const RoverDigitalOutputConfig& config) {
JsonObject role = roverControls[key].to<JsonObject>();
role["pin"] = config.pin;
role["activeLow"] = config.polarity == OutputPolarity::ActiveLow;
role["initiallyOn"] = config.initiallyOn;
};
if (hasHeadlight_) {
addDigitalRole("headlight", headlight_);
}
if (hasLaser_) {
addDigitalRole("laser", laser_);
}
}
JsonArray controls = document["controls"].to<JsonArray>();
for (const ControlRegistration& registration : controls_) {
JsonObject control = controls.add<JsonObject>();
control["id"] = registration.id;
control["name"] = registration.name;
switch (registration.type) {
case ControlType::Slider:
control["type"] = "slider";
control["min"] = registration.minimum;
control["max"] = registration.maximum;
break;
case ControlType::Button:
control["type"] = "button";
control["mode"] = buttonModeName(registration.buttonMode);
break;
case ControlType::Number:
control["type"] = "number";
control["min"] = registration.minimum;
control["max"] = registration.maximum;
break;
case ControlType::Text:
control["type"] = "text";
control["maxLength"] = registration.maximumLength;
break;
}
JsonObject output = control["output"].to<JsonObject>();
output["type"] = outputTypeName(static_cast<uint8_t>(registration.output));
if (registration.output != OutputType::Custom) {
output["pin"] = registration.pin;
}
if (registration.output == OutputType::Digital && registration.polarity == OutputPolarity::ActiveLow) {
output["activeLow"] = true;
}
}
String payload;
serializeJson(document, payload);
// ConfigurableFirmata's convenience sendSysex takes a byte-sized raw length.
// Descriptions can exceed that, so write the standard framing and each 7-bit
// pair directly. This remains one ordinary Firmata SysEx message on the wire.
Firmata.startSysex();
Firmata.write(kPeripheralFeature);
Firmata.write(kDescriptionOperation);
for (size_t index = 0; index < payload.length(); ++index) {
Firmata.sendValueAsTwo7bitBytes(static_cast<uint8_t>(payload[index]));
}
Firmata.endSysex();
}
void RoverPeripheralFirmata::dispatchCustomControl(byte argc, byte* argv) {
if (argc < 3 || ((argc - 1) % 2) != 0) {
Firmata.sendString(F("Invalid rover control payload"));
return;
}
String decoded;
decoded.reserve((argc - 1) / 2);
for (byte index = 1; index + 1 < argc; index += 2) {
if (argv[index + 1] > 1) {
Firmata.sendString(F("Invalid rover control encoding"));
return;
}
decoded += static_cast<char>(argv[index] | (argv[index + 1] << 7));
}
JsonDocument document;
if (deserializeJson(document, decoded) != DeserializationError::Ok) {
Firmata.sendString(F("Invalid rover control JSON"));
return;
}
const String controlID = document["control"].as<String>();
for (ControlRegistration& registration : controls_) {
if (registration.id != controlID || registration.output != OutputType::Custom) {
continue;
}
// The registration type is the source of truth for value conversion. This
// prevents an unexpected JSON value from silently selecting a different
// callback signature or invoking unrelated application behavior.
switch (registration.type) {
case ControlType::Slider:
if (registration.sliderCallback) {
registration.sliderCallback(document["value"].as<int>());
}
break;
case ControlType::Button:
if (registration.buttonCallback) {
registration.buttonCallback(document["value"].as<bool>());
}
break;
case ControlType::Number:
if (registration.numberCallback) {
registration.numberCallback(document["value"].as<int>());
}
break;
case ControlType::Text:
if (registration.textCallback) {
String value = document["value"].as<String>();
if (value.length() > registration.maximumLength) {
value.remove(registration.maximumLength);
}
registration.textCallback(value);
}
break;
}
return;
}
Firmata.sendString(F("Unknown rover control"));
}
void RoverPeripheralFirmata::writeDigitalPin(byte pin, bool physicalHigh) {
// Standard Firmata digital values represent the electrical pin level. roverd
// applies the advertised activeLow mapping before sending a command, keeping
// this firmware compatible with raw Firmata clients and avoiding inversion in
// two different layers.
digitalWrite(PIN_TO_DIGITAL(pin), physicalHigh ? HIGH : LOW);
Firmata.setPinState(pin, physicalHigh ? 1 : 0);
}
void RoverPeripheralFirmata::attachServo(byte pin, int minimumPulseMicroseconds, int maximumPulseMicroseconds) {
if (pin >= TOTAL_PINS || !IS_PIN_DIGITAL(pin)) {
return;
}
if (servos_[pin] == nullptr) {
servos_[pin] = new Servo();
}
if (servos_[pin]->attached()) {
servos_[pin]->detach();
}
if (minimumPulseMicroseconds > 0 && maximumPulseMicroseconds > minimumPulseMicroseconds) {
servos_[pin]->attach(PIN_TO_SERVO(pin), minimumPulseMicroseconds, maximumPulseMicroseconds);
} else {
servos_[pin]->attach(PIN_TO_SERVO(pin));
}
}
void RoverPeripheralFirmata::detachServo(byte pin) {
if (pin >= TOTAL_PINS || servos_[pin] == nullptr) {
return;
}
if (servos_[pin]->attached()) {
servos_[pin]->detach();
}
delete servos_[pin];
servos_[pin] = nullptr;
}
void RoverPeripheralFirmata::digitalPinValueCallback(byte pin, int value) {
if (instance_ == nullptr || pin >= TOTAL_PINS || Firmata.getPinMode(pin) != PIN_MODE_OUTPUT) {
return;
}
// Polarity is advertised by the peripheral and applied by roverd before this
// standard raw pin-level command reaches the ESP32.
instance_->writeDigitalPin(pin, value != 0);
}
void RoverPeripheralFirmata::systemResetCallback() {
if (instance_ != nullptr) {
instance_->reset();
}
}
@@ -0,0 +1,99 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ConfigurableFirmata.h>
#include <ESP32Servo.h>
#include <FirmataExt.h>
#include <RoverPeripheral.h>
#include <vector>
/*
* RoverPeripheralFirmata is the protocol-facing implementation behind the
* small RoverPeripheral public facade. Keeping this class private prevents
* peripheral sketches from depending on Firmata types while ordinary Firmata
* tooling can still use digital, PWM, and servo commands on the same stream.
*/
class RoverPeripheralFirmata : public FirmataFeature {
public:
explicit RoverPeripheralFirmata(const String& name);
void setName(const String& name);
void addServoSlider(const SliderControlConfig& config, const ServoOutput& output);
void addPwmSlider(const SliderControlConfig& config, const PwmOutput& output);
void addDigitalButton(const ButtonControlConfig& config, const DigitalOutput& output);
void addSlider(const SliderControlConfig& config, SliderCallback callback);
void addButton(const ButtonControlConfig& config, ButtonCallback callback);
void addNumber(const NumberControlConfig& config, NumberCallback callback);
void addText(const TextControlConfig& config, TextCallback callback);
void addRoverCameraServo(const RoverCameraServoConfig& config);
void addRoverHeadlight(const RoverDigitalOutputConfig& config);
void addRoverLaser(const RoverDigitalOutputConfig& config);
void begin(FirmataExt& extension);
void update();
// FirmataFeature methods let FirmataExt route standard and custom SysEx
// operations through the same parser that owns the serial connection.
void handleCapability(byte pin) override;
boolean handlePinMode(byte pin, int mode) override;
boolean handleSysex(byte command, byte argc, byte* argv) override;
void reset() override;
private:
enum class ControlType {
Slider,
Button,
Number,
Text,
};
enum class OutputType {
Servo,
Pwm,
Digital,
Custom,
};
struct ControlRegistration {
String id;
String name;
ControlType type;
OutputType output;
int minimum = 0;
int maximum = 0;
size_t maximumLength = 0;
ButtonMode buttonMode = ButtonMode::Momentary;
uint8_t pin = 0;
OutputPolarity polarity = OutputPolarity::ActiveHigh;
SliderCallback sliderCallback;
ButtonCallback buttonCallback;
NumberCallback numberCallback;
TextCallback textCallback;
};
String name_;
std::vector<ControlRegistration> controls_;
bool hasCameraServo_ = false;
bool hasHeadlight_ = false;
bool hasLaser_ = false;
RoverCameraServoConfig cameraServo_;
RoverDigitalOutputConfig headlight_;
RoverDigitalOutputConfig laser_;
Servo* servos_[TOTAL_PINS] = {};
void validateControlName(const String& name) const;
void validateRange(const String& name, int minimum, int maximum) const;
void buildAndSendDescription();
void dispatchCustomControl(byte argc, byte* argv);
void writeDigitalPin(byte pin, bool enabled);
void attachServo(byte pin, int minimumPulseMicroseconds = -1, int maximumPulseMicroseconds = -1);
void detachServo(byte pin);
static RoverPeripheralFirmata* instance_;
static void digitalPinValueCallback(byte pin, int value);
static void systemResetCallback();
};
@@ -0,0 +1,25 @@
[platformio]
default_envs = esp32dev
[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
lib_deps =
; Install the local package through PlatformIO's dependency manager so this
; reference project exercises the same transitive dependency behavior as an
; external project using the published Registry package.
RoverPeripheral=file://../libraries/RoverPeripheralFirmata
; This is the generic ESP32-WROOM-32/DevKitC target used by boards carrying a
; CH340 or CP210x USB-to-UART bridge. Linux normally exposes it as ttyUSB*.
[env:esp32dev]
board = esp32dev
; Native USB boards use the same sketch and Firmata stream. These flags make the
; ESP32-S3's USB CDC serial port active at boot, normally appearing as ttyACM*.
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
+97
View File
@@ -0,0 +1,97 @@
#include <RoverPeripheral.h>
namespace {
// Every example pin is present on both the classic ESP32 DevKitC and the
// ESP32-S3 DevKitC. GPIO 19 and 20 are deliberately avoided because native-USB
// S3 boards use them for USB D- and D+.
constexpr uint8_t kSpecialActionPin = 21;
int repeatCount = 1;
String displayMessage;
void runSpecialAction(bool pressed) {
// Receiving both button edges lets application hardware remain active only
// while the driver holds the momentary control.
digitalWrite(kSpecialActionPin, pressed ? HIGH : LOW);
}
void setRepeatCount(int value) {
// A real device can use this value when it starts its next animation or
// actuator sequence. Storing it keeps this reference callback non-blocking.
repeatCount = value;
}
void setDisplayMessage(const String& value) {
// Display hardware can render the stored value from updateRoverPeripheral().
// Avoiding Serial output is important because Serial belongs to Firmata.
displayMessage = value;
}
} // namespace
void configureRoverPeripheral(RoverPeripheral& io) {
io.name("Rover GPIO");
// Standard roles retain the rover's existing HUD controls while moving the
// electrical outputs to this ESP32 on either a Pi or laptop rover host.
RoverCameraServoConfig cameraServo;
cameraServo.pin = 14;
cameraServo.minimumAngleDegrees = -15;
cameraServo.maximumAngleDegrees = 30;
cameraServo.homeAngleDegrees = 0;
cameraServo.nudgeDegrees = 2;
cameraServo.minimumPulseMicroseconds = 900;
cameraServo.maximumPulseMicroseconds = 2100;
cameraServo.allowRawPulse = false;
cameraServo.inverted = false;
io.addCameraServo(cameraServo);
RoverDigitalOutputConfig headlight;
headlight.pin = 18;
headlight.polarity = OutputPolarity::ActiveHigh;
headlight.initiallyOn = false;
io.addHeadlight(headlight);
RoverDigitalOutputConfig laser;
laser.pin = 16;
laser.polarity = OutputPolarity::ActiveHigh;
laser.initiallyOn = false;
io.addLaser(laser);
pinMode(kSpecialActionPin, OUTPUT);
digitalWrite(kSpecialActionPin, LOW);
// Accessory controls render in precisely this registration order.
SliderControlConfig servoPosition;
servoPosition.name = "Servo position";
servoPosition.minimum = 0;
servoPosition.maximum = 180;
ServoOutput servoOutput;
servoOutput.pin = 13;
io.addSlider(servoPosition, servoOutput);
SliderControlConfig lightBrightness;
lightBrightness.name = "Light brightness";
lightBrightness.minimum = 0;
lightBrightness.maximum = 255;
PwmOutput lightOutput;
lightOutput.pin = 17;
io.addSlider(lightBrightness, lightOutput);
ButtonControlConfig specialAction;
specialAction.name = "Special action";
specialAction.mode = ButtonMode::Momentary;
io.addButton(specialAction, runSpecialAction);
NumberControlConfig repeats;
repeats.name = "Repeat count";
repeats.minimum = 1;
repeats.maximum = 20;
io.addNumber(repeats, setRepeatCount);
TextControlConfig message;
message.name = "Display message";
message.maximumLength = 64;
io.addText(message, setDisplayMessage);
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "perf",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"playwright": "^1.60.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+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"
+6 -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 \
@@ -142,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
+10 -4
View File
@@ -25,10 +25,6 @@ type CameraServo struct {
closed bool
}
const maxServoDegPerSec = 60.0
const servoStepInterval = 20 * time.Millisecond
const servoAngleEpsilon = 0.01
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("camera servo disabled")
@@ -132,6 +128,16 @@ func (s *CameraServo) CurrentAngle() float64 {
return s.currentAngle
}
// Configuration reports the effective public behavior advertised to the
// server. The native implementation simply returns its validated YAML config.
func (s *CameraServo) Configuration() CameraServoConfig {
return s.cfg
}
func (s *CameraServo) BackendDescription() string {
return "native GPIO"
}
func (s *CameraServo) applyPulseLocked(micros int) {
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
+11 -4
View File
@@ -11,10 +11,9 @@ type CameraServo struct{}
func NewCameraServo(_ CameraServoConfig, _ *log.Logger) (*CameraServo, error) {
/*
The Debian laptop profile starts with the laptop's built-in webcam and no
Pi PWM servo. If a laptop rover eventually grows an external servo board,
it should get its own implementation instead of reusing Raspberry Pi GPIO
assumptions.
This constructor represents only native host GPIO. The shared startup
resolver selects the normal Firmata implementation when an ESP32 provides
the role, so external hardware is not laptop-specific code.
*/
return nil, fmt.Errorf("camera servo not supported in the debian-laptop build")
}
@@ -36,3 +35,11 @@ func (c *CameraServo) SetPulseWidth(micros int) error {
func (c *CameraServo) CurrentAngle() float64 {
return 0
}
func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+8
View File
@@ -30,3 +30,11 @@ func (c *CameraServo) SetPulseWidth(micros int) error {
func (c *CameraServo) CurrentAngle() float64 {
return 0
}
func (c *CameraServo) Configuration() CameraServoConfig {
return CameraServoConfig{}
}
func (c *CameraServo) BackendDescription() string {
return "native GPIO"
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"time"
roverd "multiroombarover/pi/roverd"
"github.com/tarm/serial"
)
func main() {
var portName string
var baud int
var timeout time.Duration
var startupWait time.Duration
var controlID string
var rawValue string
flag.StringVar(&portName, "port", "", "serial device, for example /dev/ttyUSB0 or /dev/ttyACM0")
flag.IntVar(&baud, "baud", 115200, "Firmata serial baud rate")
flag.DurationVar(&timeout, "timeout", 5*time.Second, "timeout for each Firmata response")
flag.DurationVar(&startupWait, "startup-wait", 2*time.Second, "time allowed for boards that reset when the port opens")
flag.StringVar(&controlID, "control", "", "optional declared control ID to exercise")
flag.StringVar(&rawValue, "value", "", "JSON value for -control, such as 90, true, or \"hello\"")
flag.Parse()
if portName == "" {
log.Fatal("-port is required")
}
if (controlID == "") != (rawValue == "") {
log.Fatal("-control and -value must be provided together")
}
port, err := serial.OpenPort(&serial.Config{
Name: portName,
Baud: baud,
ReadTimeout: 100 * time.Millisecond,
})
if err != nil {
log.Fatalf("open %s: %v", portName, err)
}
defer port.Close()
// CH340 and native-USB development boards may reset when the host opens the
// port. Waiting here makes the same probe work with both connection styles
// without baking that diagnostic delay into the production Firmata client.
time.Sleep(startupWait)
rootContext, cancelRoot := context.WithCancel(context.Background())
defer cancelRoot()
client := roverd.NewFirmataClient(port)
client.Start(rootContext)
firmware, err := withTimeout(timeout, client.QueryFirmware)
if err != nil {
log.Fatalf("query firmware: %v", err)
}
fmt.Printf("Firmata firmware: %s %d.%d\n", firmware.Name, firmware.Major, firmware.Minor)
capabilities, err := withTimeout(timeout, client.QueryCapabilities)
if err != nil {
log.Fatalf("query capabilities: %v", err)
}
fmt.Printf("Firmata pins described: %d\n", len(capabilities))
description, err := withTimeout(timeout, client.Describe)
if err != nil {
log.Fatalf("describe rover peripheral: %v", err)
}
formatted, err := json.MarshalIndent(description, "", " ")
if err != nil {
log.Fatalf("format description: %v", err)
}
fmt.Printf("Peripheral description:\n%s\n", formatted)
if controlID != "" {
if err := exerciseControl(client, description, controlID, json.RawMessage(rawValue)); err != nil {
log.Fatalf("exercise control %q: %v", controlID, err)
}
fmt.Fprintf(os.Stdout, "Control %q accepted.\n", controlID)
}
}
// withTimeout gives every boot-time exchange its own deadline. A missing board
// therefore reports the exact handshake stage that failed instead of consuming
// one shared timeout and obscuring which response was absent.
func withTimeout[T any](timeout time.Duration, operation func(context.Context) (T, error)) (T, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return operation(ctx)
}
func exerciseControl(client *roverd.FirmataClient, description roverd.PeripheralDescription, controlID string, rawValue json.RawMessage) error {
var selected *roverd.PeripheralControl
for index := range description.Controls {
if description.Controls[index].ID == controlID {
selected = &description.Controls[index]
break
}
}
if selected == nil {
return errors.New("control is not present in the device description")
}
var value any
if err := json.Unmarshal(rawValue, &value); err != nil {
return fmt.Errorf("parse -value as JSON: %w", err)
}
// Standard outputs deliberately use standard Firmata commands. Only custom
// callbacks use the rover-peripheral CONTROL operation, which is the central
// distinction the probe is intended to validate on real hardware.
switch selected.Output.Type {
case "custom":
return client.SendPeripheralControl(selected.ID, value)
case "digital":
enabled, ok := value.(bool)
if !ok {
return errors.New("digital control value must be true or false")
}
if selected.Output.ActiveLow {
enabled = !enabled
}
if err := client.SetPinMode(byte(*selected.Output.Pin), roverd.FirmataPinModeOutput); err != nil {
return err
}
return client.SetDigitalPin(byte(*selected.Output.Pin), enabled)
case "pwm", "servo":
number, ok := value.(float64)
if !ok || number != float64(int(number)) {
return errors.New("PWM and servo control values must be whole numbers")
}
mode := roverd.FirmataPinModePWM
if selected.Output.Type == "servo" {
mode = roverd.FirmataPinModeServo
}
if err := client.SetPinMode(byte(*selected.Output.Pin), mode); err != nil {
return err
}
return client.ExtendedAnalog(byte(*selected.Output.Pin), int(number))
default:
return fmt.Errorf("unsupported output %q", selected.Output.Type)
}
}
+49 -24
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
@@ -29,6 +30,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 {
@@ -36,6 +38,19 @@ func main() {
}
defer serialPort.Close()
// Peripheral discovery is intentionally a boot-time operation. The manager
// keeps successful USB ports open across server WebSocket reconnects and is
// rebuilt only when the roverd process itself restarts.
peripherals, err := roverd.DiscoverPeripheralManager(ctx, cfg.Serial.Device, logger)
if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed: %v", err))
logger.Fatalf("discover rover peripherals: %v", err)
}
defer peripherals.Close()
for _, message := range peripherals.StartupBroadcasts() {
console.Notify(message)
}
var pulser *roverd.BRCPulser
if cfg.BRC.Enabled() {
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
@@ -60,37 +75,47 @@ func main() {
mediaSupervisor.Start(ctx)
}
var cameraServo *roverd.CameraServo
if cfg.CameraServo.Enabled {
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger)
if err != nil {
logger.Fatalf("init camera servo: %v", err)
}
defer cameraServo.Close()
// Backend selection is identical on Pi and laptop hosts: enabled native
// GPIO wins, otherwise a discovered ESP32 may provide the built-in role.
hardwareControllers, err := roverd.ResolveRoverHardwareControllers(cfg, peripherals, logger)
if err != nil {
console.Notify(fmt.Sprintf("Rover peripheral startup failed while selecting hardware: %v", err))
logger.Fatalf("resolve rover hardware controllers: %v", err)
}
defer hardwareControllers.Close()
for _, message := range hardwareControllers.StartupBroadcasts() {
console.Notify(message)
}
var headlight *roverd.GPIOToggle
if cfg.Headlight.Enabled {
headlight, err = roverd.NewGPIOToggle("headlight", cfg.Headlight, logger)
if err != nil {
logger.Fatalf("init headlight: %v", err)
// A peripheral is never hot-reconnected. Report the first terminal serial
// failure for each discovered board and tell the local operator exactly what
// recovery action the fixed boot-time lifecycle requires.
go func() {
for {
select {
case failure := <-peripherals.Failures():
console.Notify(fmt.Sprintf(
"Rover peripheral %q (%s) disconnected: %v. Reconnect it and restart roverd.",
failure.Name,
failure.ID,
failure.Err,
))
case <-ctx.Done():
return
}
}
defer headlight.Close()
}
var laser *roverd.GPIOToggle
if cfg.Laser.Enabled {
laser, err = roverd.NewGPIOToggle("laser", cfg.Laser, logger)
if err != nil {
logger.Fatalf("init laser: %v", err)
}
defer laser.Close()
}
}()
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, hardwareControllers.CameraServo, hardwareControllers.Headlight, hardwareControllers.Laser, peripherals, 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 {
+23 -13
View File
@@ -1,19 +1,22 @@
package roverd
import "encoding/json"
type helloMessage struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
Headlight GPIOToggleConfig `json:"headlight"`
Laser GPIOToggleConfig `json:"laser"`
Private PrivateConfig `json:"private"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
Horn HornConfig `json:"horn"`
Headlight GPIOToggleConfig `json:"headlight"`
Laser GPIOToggleConfig `json:"laser"`
Peripherals []RoverPeripheralMetadata `json:"peripherals,omitempty"`
Private PrivateConfig `json:"private"`
}
type sensorMessage struct {
@@ -45,6 +48,7 @@ type inboundMessage struct {
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
Headlight *togglePayload `json:"headlight,omitempty"`
Laser *togglePayload `json:"laser,omitempty"`
Peripheral *peripheralPayload `json:"peripheral,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
// Update is intentionally just a marker payload. The server can request the
@@ -103,6 +107,12 @@ type togglePayload struct {
Action string `json:"action"`
}
type peripheralPayload struct {
ID string `json:"id"`
Control string `json:"control"`
Value json.RawMessage `json:"value"`
}
type songPayload struct {
Slot *int `json:"slot,omitempty"`
Notes []songNote `json:"notes"`
+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")
}
+664
View File
@@ -0,0 +1,664 @@
package roverd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sync"
)
// Firmata command and mode constants are kept here instead of scattering raw
// bytes through the peripheral code. The values come directly from the Firmata
// protocol, so captures from a rover can be compared with the specification.
const (
firmataReportVersion byte = 0xF9
firmataSetPinMode byte = 0xF4
firmataSetDigitalPin byte = 0xF5
firmataStartSysex byte = 0xF0
firmataEndSysex byte = 0xF7
firmataReportFirmware byte = 0x79
firmataCapabilityQuery byte = 0x6B
firmataCapabilityReply byte = 0x6C
firmataExtendedAnalog byte = 0x6F
firmataServoConfig byte = 0x70
firmataPeripheralFeature byte = 0x01
firmataPeripheralDescribe byte = 0x00
firmataPeripheralDescription byte = 0x01
firmataPeripheralControl byte = 0x02
firmataMaximumSysexDataBytes = 252
FirmataPinModeOutput byte = 0x01
FirmataPinModePWM byte = 0x03
FirmataPinModeServo byte = 0x04
)
// FirmataMessage is the transport-neutral result of parsing one complete
// Firmata message. For SysEx messages Command is the SysEx feature byte and
// Data is everything between that feature byte and END_SYSEX.
type FirmataMessage struct {
Command byte
Data []byte
Sysex bool
}
// FirmataParser incrementally parses a byte stream. USB serial reads may split
// a message anywhere or combine several messages, so parsing whole Read calls
// as though they were packets would intermittently corrupt valid traffic.
type FirmataParser struct {
inSysex bool
sysex []byte
command byte
data []byte
expected int
}
// Feed accepts any fragment of the serial stream and returns every complete
// message found in it, preserving wire order.
func (p *FirmataParser) Feed(fragment []byte) ([]FirmataMessage, error) {
var messages []FirmataMessage
for _, value := range fragment {
if p.inSysex {
switch {
case value == firmataEndSysex:
if len(p.sysex) == 0 {
p.resetSysex()
return messages, errors.New("Firmata SysEx message is missing a feature byte")
}
messages = append(messages, FirmataMessage{
Command: p.sysex[0],
Data: append([]byte(nil), p.sysex[1:]...),
Sysex: true,
})
p.resetSysex()
case value&0x80 != 0:
// Bytes inside SysEx must be seven-bit clean. Reset immediately so
// a damaged frame cannot consume every later message on the port.
p.resetSysex()
return messages, fmt.Errorf("invalid 8-bit value 0x%02x inside Firmata SysEx", value)
default:
p.sysex = append(p.sysex, value)
}
continue
}
if value == firmataStartSysex {
p.inSysex = true
p.sysex = p.sysex[:0]
p.resetFixed()
continue
}
if value&0x80 != 0 {
p.command = value
p.data = p.data[:0]
p.expected = firmataDataLength(value)
if p.expected == 0 {
messages = append(messages, FirmataMessage{Command: value})
p.resetFixed()
}
continue
}
// Stray data before a status byte is harmless serial noise. Firmata
// has no framing information that could assign it to a command.
if p.expected == 0 {
continue
}
p.data = append(p.data, value)
if len(p.data) == p.expected {
messages = append(messages, FirmataMessage{
Command: p.command,
Data: append([]byte(nil), p.data...),
})
p.resetFixed()
}
}
return messages, nil
}
func (p *FirmataParser) resetSysex() {
p.inSysex = false
p.sysex = p.sysex[:0]
}
func (p *FirmataParser) resetFixed() {
p.command = 0
p.data = p.data[:0]
p.expected = 0
}
// firmataDataLength returns the number of seven-bit data bytes used by the
// fixed-length messages relevant to normal Firmata traffic. Unknown system
// commands are treated as single-byte messages so they cannot stall parsing of
// the rover-peripheral SysEx frames that follow them.
func firmataDataLength(command byte) int {
switch command {
case firmataReportVersion, firmataSetPinMode, firmataSetDigitalPin:
return 2
}
switch command & 0xF0 {
case 0x80, 0x90, 0xA0, 0xE0:
return 2
case 0xC0, 0xD0:
return 1
default:
return 0
}
}
// EncodeFirmata7Bit converts arbitrary bytes into the two-byte representation
// required inside Firmata SysEx. Keeping this transform below the JSON layer
// means firmware authors and UI code never need to think about wire encoding.
func EncodeFirmata7Bit(raw []byte) []byte {
encoded := make([]byte, 0, len(raw)*2)
for _, value := range raw {
encoded = append(encoded, value&0x7F, (value>>7)&0x01)
}
return encoded
}
// DecodeFirmata7Bit reverses EncodeFirmata7Bit and rejects malformed pairs.
func DecodeFirmata7Bit(encoded []byte) ([]byte, error) {
if len(encoded)%2 != 0 {
return nil, fmt.Errorf("Firmata 7-bit payload has odd length %d", len(encoded))
}
decoded := make([]byte, 0, len(encoded)/2)
for index := 0; index < len(encoded); index += 2 {
low, high := encoded[index], encoded[index+1]
if low&0x80 != 0 || high > 1 {
return nil, fmt.Errorf("invalid Firmata 7-bit pair at byte %d", index)
}
decoded = append(decoded, low|(high<<7))
}
return decoded, nil
}
// PeripheralDescription is generated by the ESP32 at boot. Controls is a slice
// intentionally: registration order is part of the UI contract and must never
// be replaced by map iteration or alphabetical sorting.
type PeripheralDescription struct {
Name string `json:"name"`
RoverControls PeripheralRoverControls `json:"roverControls,omitempty"`
Controls []PeripheralControl `json:"controls"`
}
type PeripheralRoverControls struct {
CameraServo *PeripheralCameraServo `json:"cameraServo,omitempty"`
Headlight *PeripheralDigitalRole `json:"headlight,omitempty"`
Laser *PeripheralDigitalRole `json:"laser,omitempty"`
}
type PeripheralCameraServo struct {
Pin int `json:"pin"`
MinimumAngleDegrees float64 `json:"minimumAngleDegrees"`
MaximumAngleDegrees float64 `json:"maximumAngleDegrees"`
HomeAngleDegrees float64 `json:"homeAngleDegrees"`
NudgeDegrees float64 `json:"nudgeDegrees"`
MinimumPulseMicroseconds int `json:"minimumPulseMicroseconds"`
MaximumPulseMicroseconds int `json:"maximumPulseMicroseconds"`
AllowRawPulse bool `json:"allowRawPulse"`
Inverted bool `json:"inverted"`
}
type PeripheralDigitalRole struct {
Pin int `json:"pin"`
ActiveLow bool `json:"activeLow"`
InitiallyOn bool `json:"initiallyOn"`
}
type PeripheralControl struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Mode string `json:"mode,omitempty"`
Minimum *int `json:"min,omitempty"`
Maximum *int `json:"max,omitempty"`
MaximumLength *int `json:"maxLength,omitempty"`
Output PeripheralOutput `json:"output"`
}
type PeripheralOutput struct {
Type string `json:"type"`
Pin *int `json:"pin,omitempty"`
ActiveLow bool `json:"activeLow,omitempty"`
}
// Validate catches authoring mistakes at connection time, where the error can
// name the offending peripheral, instead of allowing a malformed declaration
// to turn into a confusing no-op later when a driver uses the control.
func (description PeripheralDescription) Validate() error {
if description.Name == "" {
return errors.New("peripheral description requires a name")
}
if camera := description.RoverControls.CameraServo; camera != nil {
if err := validateFirmataPin("cameraServo", camera.Pin); err != nil {
return err
}
if camera.MinimumAngleDegrees >= camera.MaximumAngleDegrees {
return errors.New("cameraServo angle range must be increasing")
}
if camera.HomeAngleDegrees < camera.MinimumAngleDegrees || camera.HomeAngleDegrees > camera.MaximumAngleDegrees {
return errors.New("cameraServo home angle must be inside its angle range")
}
if camera.NudgeDegrees <= 0 {
return errors.New("cameraServo nudge must be positive")
}
if camera.MinimumPulseMicroseconds <= 0 || camera.MaximumPulseMicroseconds <= camera.MinimumPulseMicroseconds {
return errors.New("cameraServo pulse range must be positive and increasing")
}
}
if role := description.RoverControls.Headlight; role != nil {
if err := validateFirmataPin("headlight", role.Pin); err != nil {
return err
}
}
if role := description.RoverControls.Laser; role != nil {
if err := validateFirmataPin("laser", role.Pin); err != nil {
return err
}
}
seen := make(map[string]struct{}, len(description.Controls))
for index, control := range description.Controls {
if control.ID == "" || control.Name == "" {
return fmt.Errorf("control %d requires both id and name", index)
}
if _, exists := seen[control.ID]; exists {
return fmt.Errorf("control id %q is duplicated", control.ID)
}
seen[control.ID] = struct{}{}
switch control.Type {
case "slider", "number":
if control.Minimum == nil || control.Maximum == nil || *control.Minimum > *control.Maximum {
return fmt.Errorf("control %q requires a valid min and max", control.ID)
}
case "button":
if control.Mode != "toggle" && control.Mode != "momentary" {
return fmt.Errorf("button %q requires toggle or momentary mode", control.ID)
}
case "text":
if control.MaximumLength == nil || *control.MaximumLength <= 0 {
return fmt.Errorf("text control %q requires a positive maxLength", control.ID)
}
default:
return fmt.Errorf("control %q has unsupported type %q", control.ID, control.Type)
}
switch control.Output.Type {
case "digital":
if control.Output.Pin == nil {
return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type)
}
if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil {
return err
}
if control.Type != "button" {
return fmt.Errorf("digital output control %q must be a button", control.ID)
}
case "pwm", "servo":
if control.Output.Pin == nil {
return fmt.Errorf("control %q output %q requires a pin", control.ID, control.Output.Type)
}
if err := validateFirmataPin("control "+control.ID, *control.Output.Pin); err != nil {
return err
}
if control.Type != "slider" && control.Type != "number" {
return fmt.Errorf("%s output control %q must be a slider or number", control.Output.Type, control.ID)
}
case "custom":
default:
return fmt.Errorf("control %q has unsupported output %q", control.ID, control.Output.Type)
}
}
return nil
}
func validateFirmataPin(owner string, pin int) error {
// Firmata represents pin numbers with one seven-bit byte. Rejecting values
// outside that wire range avoids silently wrapping a declaration when it is
// converted to a byte for output commands.
if pin < 0 || pin > 127 {
return fmt.Errorf("%s pin must be between 0 and 127", owner)
}
return nil
}
// FirmataFirmware identifies the implementation answering the standard
// REPORT_FIRMWARE query. It is diagnostic metadata, not a protocol gate.
type FirmataFirmware struct {
Major int
Minor int
Name string
}
// FirmataPinCapability is one mode/resolution pair from CAPABILITY_RESPONSE.
type FirmataPinCapability struct {
Mode byte
Resolution byte
}
// FirmataClient owns one already-open serial connection. Its reader goroutine
// separates arbitrary USB read boundaries from request/response handling while
// writeMu prevents two commands from interleaving on the byte stream.
type FirmataClient struct {
connection io.ReadWriteCloser
parser FirmataParser
messages chan FirmataMessage
errors chan error
writeMu sync.Mutex
requestMu sync.Mutex
stateMu sync.RWMutex
terminalErr error
// terminalErrorHandler is invoked only for the first non-timeout read
// failure while the client context remains active. PeripheralManager uses it
// to turn an unexpected USB loss into one operator-facing broadcast.
terminalErrorHandler func(error)
}
func NewFirmataClient(connection io.ReadWriteCloser) *FirmataClient {
return &FirmataClient{
connection: connection,
messages: make(chan FirmataMessage, 16),
errors: make(chan error, 1),
}
}
// Start begins consuming the serial stream. The caller still owns the port and
// closes it during shutdown; this makes the client usable with both real serial
// ports and deterministic in-memory test connections.
func (client *FirmataClient) Start(ctx context.Context) {
go client.readLoop(ctx)
}
func (client *FirmataClient) readLoop(ctx context.Context) {
buffer := make([]byte, 256)
for {
count, err := client.connection.Read(buffer)
if count > 0 {
messages, parseErr := client.parser.Feed(buffer[:count])
if parseErr != nil {
client.publishError(ctx, parseErr)
return
}
for _, message := range messages {
select {
case client.messages <- message:
case <-ctx.Done():
return
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
// tarm/serial represents an ordinary ReadTimeout with io.EOF. A
// Firmata connection is expected to be quiet between commands, so
// treating that timeout as a closed device kills the reader before
// the next request can receive its reply. A real USB removal is
// reported by the serial driver as a non-EOF error.
select {
case <-ctx.Done():
return
default:
continue
}
}
client.publishError(ctx, err)
return
}
select {
case <-ctx.Done():
return
default:
}
}
}
func (client *FirmataClient) publishError(ctx context.Context, err error) {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil && ctx.Err() == nil {
handler(err)
}
select {
case client.errors <- err:
case <-ctx.Done():
default:
}
}
func (client *FirmataClient) recordTerminalError(err error) (bool, func(error)) {
client.stateMu.Lock()
defer client.stateMu.Unlock()
firstTerminalError := client.terminalErr == nil
if client.terminalErr == nil {
client.terminalErr = err
}
handler := client.terminalErrorHandler
return firstTerminalError, handler
}
// SetTerminalErrorHandler registers the one-shot observer used after a device
// has completed discovery. If the connection already failed, the observer is
// called immediately so a narrow handshake-to-registration race is not lost.
func (client *FirmataClient) SetTerminalErrorHandler(handler func(error)) {
client.stateMu.Lock()
client.terminalErrorHandler = handler
terminalErr := client.terminalErr
client.stateMu.Unlock()
if terminalErr != nil && handler != nil {
handler(terminalErr)
}
}
func (client *FirmataClient) write(message []byte) error {
client.writeMu.Lock()
defer client.writeMu.Unlock()
client.stateMu.RLock()
terminalErr := client.terminalErr
client.stateMu.RUnlock()
if terminalErr != nil {
return fmt.Errorf("Firmata connection unavailable: %w", terminalErr)
}
written, err := client.connection.Write(message)
if err != nil {
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err
}
if written != len(message) {
err := fmt.Errorf("short Firmata write %d/%d", written, len(message))
firstTerminalError, handler := client.recordTerminalError(err)
if firstTerminalError && handler != nil {
handler(err)
}
return err
}
return nil
}
func (client *FirmataClient) writeSysex(command byte, data []byte) error {
message := make([]byte, 0, len(data)+3)
message = append(message, firmataStartSysex, command)
message = append(message, data...)
message = append(message, firmataEndSysex)
return client.write(message)
}
func (client *FirmataClient) waitFor(ctx context.Context, match func(FirmataMessage) bool) (FirmataMessage, error) {
for {
select {
case message := <-client.messages:
if match(message) {
return message, nil
}
case err := <-client.errors:
return FirmataMessage{}, err
case <-ctx.Done():
return FirmataMessage{}, ctx.Err()
}
}
}
func (client *FirmataClient) QueryFirmware(ctx context.Context) (FirmataFirmware, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataReportFirmware, nil); err != nil {
return FirmataFirmware{}, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataReportFirmware
})
if err != nil {
return FirmataFirmware{}, err
}
if len(message.Data) < 2 {
return FirmataFirmware{}, errors.New("Firmata firmware response is missing version bytes")
}
name, err := DecodeFirmata7Bit(message.Data[2:])
if err != nil {
return FirmataFirmware{}, fmt.Errorf("decode Firmata firmware name: %w", err)
}
return FirmataFirmware{Major: int(message.Data[0]), Minor: int(message.Data[1]), Name: string(name)}, nil
}
func (client *FirmataClient) QueryCapabilities(ctx context.Context) ([][]FirmataPinCapability, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataCapabilityQuery, nil); err != nil {
return nil, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataCapabilityReply
})
if err != nil {
return nil, err
}
return parseFirmataCapabilities(message.Data)
}
func parseFirmataCapabilities(data []byte) ([][]FirmataPinCapability, error) {
var pins [][]FirmataPinCapability
var pin []FirmataPinCapability
for index := 0; index < len(data); {
if data[index] == 0x7F {
pins = append(pins, pin)
pin = nil
index++
continue
}
if index+1 >= len(data) {
return nil, errors.New("Firmata capability response ends inside a mode pair")
}
pin = append(pin, FirmataPinCapability{Mode: data[index], Resolution: data[index+1]})
index += 2
}
if pin != nil {
return nil, errors.New("Firmata capability response is missing its final pin separator")
}
return pins, nil
}
func (client *FirmataClient) Describe(ctx context.Context) (PeripheralDescription, error) {
client.requestMu.Lock()
defer client.requestMu.Unlock()
if err := client.writeSysex(firmataPeripheralFeature, []byte{firmataPeripheralDescribe}); err != nil {
return PeripheralDescription{}, err
}
message, err := client.waitFor(ctx, func(message FirmataMessage) bool {
return message.Sysex && message.Command == firmataPeripheralFeature && len(message.Data) > 0 && message.Data[0] == firmataPeripheralDescription
})
if err != nil {
return PeripheralDescription{}, err
}
raw, err := DecodeFirmata7Bit(message.Data[1:])
if err != nil {
return PeripheralDescription{}, fmt.Errorf("decode peripheral description: %w", err)
}
var description PeripheralDescription
if err := json.Unmarshal(raw, &description); err != nil {
return PeripheralDescription{}, fmt.Errorf("parse peripheral description: %w", err)
}
if err := description.Validate(); err != nil {
return PeripheralDescription{}, fmt.Errorf("validate peripheral description: %w", err)
}
return description, nil
}
func (client *FirmataClient) SetPinMode(pin, mode byte) error {
return client.write([]byte{firmataSetPinMode, pin & 0x7F, mode & 0x7F})
}
func (client *FirmataClient) SetDigitalPin(pin byte, enabled bool) error {
value := byte(0)
if enabled {
value = 1
}
return client.write([]byte{firmataSetDigitalPin, pin & 0x7F, value})
}
func (client *FirmataClient) ExtendedAnalog(pin byte, value int) error {
if value < 0 {
return fmt.Errorf("Firmata analog value cannot be negative: %d", value)
}
payload := []byte{pin & 0x7F}
// Firmata encodes integers as many seven-bit chunks as necessary. Zero
// still needs one value byte so the receiver can distinguish it from a
// message that contains only the pin.
for {
payload = append(payload, byte(value&0x7F))
value >>= 7
if value == 0 {
break
}
}
return client.writeSysex(firmataExtendedAnalog, payload)
}
func (client *FirmataClient) ConfigureServo(pin byte, minimumPulseMicroseconds, maximumPulseMicroseconds int) error {
if minimumPulseMicroseconds <= 0 || maximumPulseMicroseconds <= minimumPulseMicroseconds {
return errors.New("servo pulse range must be positive and increasing")
}
payload := []byte{
pin & 0x7F,
byte(minimumPulseMicroseconds & 0x7F), byte((minimumPulseMicroseconds >> 7) & 0x7F),
byte(maximumPulseMicroseconds & 0x7F), byte((maximumPulseMicroseconds >> 7) & 0x7F),
}
return client.writeSysex(firmataServoConfig, payload)
}
func (client *FirmataClient) SendPeripheralControl(controlID string, value any) error {
payload, err := json.Marshal(struct {
Control string `json:"control"`
Value any `json:"value"`
}{Control: controlID, Value: value})
if err != nil {
return fmt.Errorf("encode peripheral control: %w", err)
}
data := append([]byte{firmataPeripheralControl}, EncodeFirmata7Bit(payload)...)
// ConfigurableFirmata on ESP32 stores at most 252 bytes including the SysEx
// feature byte. Refuse a value that the board would otherwise discard as an
// incomplete frame; this is a transport constraint, not an application-level
// text policy.
if len(data)+1 > firmataMaximumSysexDataBytes {
return fmt.Errorf("peripheral control needs %d SysEx data bytes; Firmata accepts at most %d", len(data)+1, firmataMaximumSysexDataBytes)
}
return client.writeSysex(firmataPeripheralFeature, data)
}
+304
View File
@@ -0,0 +1,304 @@
package roverd
import (
"fmt"
"log"
"math"
"strings"
"sync"
"time"
)
// FirmataCameraServo preserves the established logical camera movement model
// while replacing only the final physical write. The ESP32 receives ordinary
// Firmata servo configuration and angle messages, regardless of rover host.
type FirmataCameraServo struct {
cfg CameraServoConfig
client *FirmataClient
pin byte
peripheralID string
mu sync.Mutex
currentAngle float64
desiredAngle float64
lastMove time.Time
moving bool
stopCh chan struct{}
closed bool
}
func newFirmataCameraServo(peripheral *managedPeripheral, declaration PeripheralCameraServo, logger *log.Logger) (*FirmataCameraServo, error) {
cfg := CameraServoConfig{
Enabled: true,
Pin: declaration.Pin,
FreqHz: 50,
CycleLen: 20000,
MinPulseUs: declaration.MinimumPulseMicroseconds,
MaxPulseUs: declaration.MaximumPulseMicroseconds,
MinAngle: declaration.MinimumAngleDegrees,
MaxAngle: declaration.MaximumAngleDegrees,
HomeAngle: declaration.HomeAngleDegrees,
NudgeDegrees: declaration.NudgeDegrees,
AllowRawPulse: declaration.AllowRawPulse,
Invert: declaration.Inverted,
}
servo := &FirmataCameraServo{
cfg: cfg,
client: peripheral.client,
pin: byte(declaration.Pin),
peripheralID: peripheral.metadata.ID,
stopCh: make(chan struct{}),
}
// SERVO_CONFIG establishes the peripheral-owned pulse calibration before
// selecting servo mode. This is standard Firmata, not a rover extension.
if err := servo.client.ConfigureServo(servo.pin, cfg.MinPulseUs, cfg.MaxPulseUs); err != nil {
return nil, fmt.Errorf("configure Firmata servo: %w", err)
}
if err := servo.client.SetPinMode(servo.pin, FirmataPinModeServo); err != nil {
return nil, fmt.Errorf("select Firmata servo mode: %w", err)
}
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
return nil, err
}
logger.Printf("camera servo using ESP32 %s pin %d (%.1f..%.1f deg)", peripheral.metadata.ID, declaration.Pin, cfg.MinAngle, cfg.MaxAngle)
return servo, nil
}
func (servo *FirmataCameraServo) SetAngle(angle float64) error {
servo.mu.Lock()
defer servo.mu.Unlock()
return servo.setAngleLocked(angle)
}
func (servo *FirmataCameraServo) setAngleLocked(angle float64) error {
if servo.closed {
return errorsNewControllerClosed("camera servo")
}
servo.desiredAngle = clampFloat(angle, servo.cfg.MinAngle, servo.cfg.MaxAngle)
limited := servo.rateLimitAngleLocked(servo.desiredAngle)
if err := servo.writeAngleLocked(limited); err != nil {
return err
}
servo.currentAngle = limited
if math.Abs(limited-servo.desiredAngle) > servoAngleEpsilon {
servo.startMoveLoopLocked()
}
return nil
}
func (servo *FirmataCameraServo) Nudge(delta float64) error {
servo.mu.Lock()
defer servo.mu.Unlock()
if delta == 0 {
delta = servo.cfg.NudgeDegrees
}
return servo.setAngleLocked(servo.currentAngle + delta)
}
func (servo *FirmataCameraServo) SetPulseWidth(micros int) error {
servo.mu.Lock()
defer servo.mu.Unlock()
if !servo.cfg.AllowRawPulse {
return fmt.Errorf("raw pulse commands disabled")
}
if micros <= 0 {
return fmt.Errorf("pulse width must be > 0")
}
pulse := clampInt(micros, servo.cfg.MinPulseUs, servo.cfg.MaxPulseUs)
return servo.setAngleLocked(servo.pulseToAngle(pulse))
}
func (servo *FirmataCameraServo) CurrentAngle() float64 {
servo.mu.Lock()
defer servo.mu.Unlock()
return servo.currentAngle
}
func (servo *FirmataCameraServo) Configuration() CameraServoConfig {
return servo.cfg
}
func (servo *FirmataCameraServo) BackendDescription() string {
return "ESP32 " + servo.peripheralID
}
func (servo *FirmataCameraServo) Close() {
servo.mu.Lock()
defer servo.mu.Unlock()
if servo.closed {
return
}
// Returning home matches the native Pi implementation. Any write failure is
// ignored during shutdown because the serial connection may already be gone.
_ = servo.writeAngleLocked(servo.cfg.HomeAngle)
close(servo.stopCh)
servo.closed = true
}
func (servo *FirmataCameraServo) writeAngleLocked(angle float64) error {
rangeDegrees := servo.cfg.MaxAngle - servo.cfg.MinAngle
normalized := (angle - servo.cfg.MinAngle) / rangeDegrees
normalized = math.Max(0, math.Min(1, normalized))
if servo.cfg.Invert {
normalized = 1 - normalized
}
// Standard Firmata servo values are positions from 0 through 180. Pulse
// calibration was already supplied through SERVO_CONFIG above.
position := int(math.Round(normalized * 180))
return servo.client.ExtendedAnalog(servo.pin, position)
}
func (servo *FirmataCameraServo) pulseToAngle(pulse int) float64 {
normalized := float64(pulse-servo.cfg.MinPulseUs) / float64(servo.cfg.MaxPulseUs-servo.cfg.MinPulseUs)
if servo.cfg.Invert {
normalized = 1 - normalized
}
return servo.cfg.MinAngle + normalized*(servo.cfg.MaxAngle-servo.cfg.MinAngle)
}
func (servo *FirmataCameraServo) rateLimitAngleLocked(target float64) float64 {
now := time.Now()
if servo.lastMove.IsZero() {
servo.lastMove = now
}
elapsed := now.Sub(servo.lastMove).Seconds()
if elapsed > servoStepInterval.Seconds() {
elapsed = servoStepInterval.Seconds()
}
maximumDelta := maxServoDegPerSec * elapsed
delta := target - servo.currentAngle
if math.Abs(delta) <= maximumDelta {
servo.lastMove = now
return target
}
servo.lastMove = now
if delta > 0 {
return servo.currentAngle + maximumDelta
}
return servo.currentAngle - maximumDelta
}
func (servo *FirmataCameraServo) startMoveLoopLocked() {
if servo.moving || servo.closed {
return
}
servo.moving = true
go func() {
ticker := time.NewTicker(servoStepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
servo.mu.Lock()
if servo.closed || math.Abs(servo.currentAngle-servo.desiredAngle) <= servoAngleEpsilon {
servo.moving = false
servo.mu.Unlock()
return
}
limited := servo.rateLimitAngleLocked(servo.desiredAngle)
if err := servo.writeAngleLocked(limited); err != nil {
// A failed serial write makes further automatic steps pointless.
// The next user command returns the connection error normally.
servo.moving = false
servo.mu.Unlock()
return
}
servo.currentAngle = limited
servo.mu.Unlock()
case <-servo.stopCh:
return
}
}
}()
}
// FirmataToggle owns logical state exactly like GPIOToggle but sends the final
// electrical level through Firmata's standard digital-pin command.
type FirmataToggle struct {
cfg GPIOToggleConfig
name string
client *FirmataClient
pin byte
peripheralID string
mu sync.Mutex
on bool
closed bool
}
func newFirmataToggle(name string, peripheral *managedPeripheral, declaration PeripheralDigitalRole, logger *log.Logger) (*FirmataToggle, error) {
cfg := GPIOToggleConfig{Enabled: true, GPIOPin: declaration.Pin, InitialOn: declaration.InitiallyOn, ActiveLow: declaration.ActiveLow}
toggle := &FirmataToggle{
cfg: cfg,
name: name,
client: peripheral.client,
pin: byte(declaration.Pin),
peripheralID: peripheral.metadata.ID,
on: cfg.InitialOn,
}
if err := toggle.client.SetPinMode(toggle.pin, FirmataPinModeOutput); err != nil {
return nil, fmt.Errorf("select Firmata output mode: %w", err)
}
if err := toggle.writeLocked(toggle.on); err != nil {
return nil, fmt.Errorf("initialize Firmata output: %w", err)
}
logger.Printf("%s using ESP32 %s pin %d (initial=%v activeLow=%v)", name, peripheral.metadata.ID, declaration.Pin, cfg.InitialOn, cfg.ActiveLow)
return toggle, nil
}
func (toggle *FirmataToggle) HandleAction(action string) error {
toggle.mu.Lock()
defer toggle.mu.Unlock()
if toggle.closed {
return errorsNewControllerClosed(toggle.name)
}
switch strings.ToLower(strings.TrimSpace(action)) {
case "", "toggle":
return toggle.setLocked(!toggle.on)
case "on":
return toggle.setLocked(true)
case "off":
return toggle.setLocked(false)
default:
return fmt.Errorf("unknown action %q", action)
}
}
func (toggle *FirmataToggle) setLocked(on bool) error {
if err := toggle.writeLocked(on); err != nil {
return err
}
toggle.on = on
return nil
}
func (toggle *FirmataToggle) writeLocked(on bool) error {
physicalHigh := on
if toggle.cfg.ActiveLow {
physicalHigh = !physicalHigh
}
return toggle.client.SetDigitalPin(toggle.pin, physicalHigh)
}
func (toggle *FirmataToggle) On() bool {
toggle.mu.Lock()
defer toggle.mu.Unlock()
return toggle.on
}
func (toggle *FirmataToggle) Configuration() GPIOToggleConfig {
return toggle.cfg
}
func (toggle *FirmataToggle) BackendDescription() string {
return "ESP32 " + toggle.peripheralID
}
func (toggle *FirmataToggle) Close() {
toggle.mu.Lock()
defer toggle.mu.Unlock()
toggle.closed = true
}
func errorsNewControllerClosed(name string) error {
return fmt.Errorf("%s controller closed", name)
}
@@ -0,0 +1,163 @@
package roverd
import (
"bytes"
"context"
"log"
"testing"
)
func TestDisabledNativeRolesResolveToFirmataOnEveryHostBuild(t *testing.T) {
description := PeripheralDescription{
Name: "Rover GPIO",
RoverControls: PeripheralRoverControls{
CameraServo: &PeripheralCameraServo{
Pin: 14, MinimumAngleDegrees: -15, MaximumAngleDegrees: 30,
HomeAngleDegrees: 0, NudgeDegrees: 2,
MinimumPulseMicroseconds: 900, MaximumPulseMicroseconds: 2100,
},
Headlight: &PeripheralDigitalRole{Pin: 18, ActiveLow: true, InitiallyOn: true},
Laser: &PeripheralDigitalRole{Pin: 16, ActiveLow: false, InitiallyOn: false},
},
Controls: []PeripheralControl{},
}
connection := scriptedPeripheralConnection(t, description)
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/rover-gpio"}, map[string]*scriptedConnection{"/dev/rover-gpio": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
// All native entries are disabled, exactly as they can be on either a Pi or
// laptop rover. The shared resolver must therefore select every ESP32 role.
baseline := len(connection.Bytes())
controllers, err := ResolveRoverHardwareControllers(&Config{}, manager, discardLogger())
if err != nil {
t.Fatalf("resolve: %v", err)
}
defer controllers.Close()
if controllers.CameraServo == nil || controllers.Headlight == nil || controllers.Laser == nil {
t.Fatalf("missing Firmata controller: %#v", controllers)
}
if !controllers.CameraServo.Configuration().Enabled || !controllers.Headlight.Configuration().Enabled || !controllers.Laser.Configuration().Enabled {
t.Fatal("ESP32-backed roles were not advertised as enabled")
}
wantHardwareBroadcast := "Rover hardware ready: camera servo via ESP32 firmata-0, headlight via ESP32 firmata-0, laser via ESP32 firmata-0."
if messages := controllers.StartupBroadcasts(); len(messages) != 1 || messages[0] != wantHardwareBroadcast {
t.Fatalf("hardware broadcasts = %#v, want %q", messages, wantHardwareBroadcast)
}
// Initialization uses only standard Firmata: servo calibration and mode,
// followed by the home position and digital initial states. The active-low
// headlight starts logically on, so its physical output is low.
writes := connection.Bytes()[baseline:]
wantPrefix := []byte{
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
firmataSetPinMode, 14, FirmataPinModeServo,
firmataStartSysex, firmataExtendedAnalog, 14, 60, firmataEndSysex,
firmataSetPinMode, 18, FirmataPinModeOutput,
firmataSetDigitalPin, 18, 0,
firmataSetPinMode, 16, FirmataPinModeOutput,
firmataSetDigitalPin, 16, 0,
}
if !bytes.Equal(writes, wantPrefix) {
t.Fatalf("initial controller bytes = %v, want %v", writes, wantPrefix)
}
baseline = len(connection.Bytes())
if err := controllers.Headlight.HandleAction("off"); err != nil {
t.Fatalf("turn headlight off: %v", err)
}
if controllers.Headlight.On() {
t.Fatal("headlight remained logically on")
}
// Active-low means logical off becomes a high electrical output.
if got, want := connection.Bytes()[baseline:], []byte{firmataSetDigitalPin, 18, 1}; !bytes.Equal(got, want) {
t.Fatalf("headlight bytes = %v, want %v", got, want)
}
}
func TestMissingNativeAndFirmataRolesRemainDisabled(t *testing.T) {
manager := &PeripheralManager{byID: make(map[string]*managedPeripheral)}
controllers, err := ResolveRoverHardwareControllers(&Config{}, manager, discardLogger())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if controllers.CameraServo != nil || controllers.Headlight != nil || controllers.Laser != nil {
t.Fatalf("unexpected controllers without providers: %#v", controllers)
}
}
func TestEnabledNativeRolesWinEvenWithSeveralFirmataProviders(t *testing.T) {
roleDescription := PeripheralDescription{RoverControls: PeripheralRoverControls{
CameraServo: &PeripheralCameraServo{},
Headlight: &PeripheralDigitalRole{},
Laser: &PeripheralDigitalRole{},
}}
manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral),
peripherals: []*managedPeripheral{
{metadata: RoverPeripheralMetadata{ID: "firmata-0"}, description: roleDescription},
{metadata: RoverPeripheralMetadata{ID: "firmata-1"}, description: roleDescription},
},
}
cfg := &Config{
CameraServo: CameraServoConfig{Enabled: true},
Headlight: GPIOToggleConfig{Enabled: true},
Laser: GPIOToggleConfig{Enabled: true},
}
nativeCamera := &testCameraServoController{cfg: cfg.CameraServo}
nativeToggles := map[string]*testToggleController{}
factories := nativeHardwareControllerFactories{
newCameraServo: func(_ CameraServoConfig, _ *log.Logger) (CameraServoController, error) {
return nativeCamera, nil
},
newToggle: func(name string, config GPIOToggleConfig, _ *log.Logger) (ToggleController, error) {
controller := &testToggleController{cfg: config}
nativeToggles[name] = controller
return controller, nil
},
}
// Duplicate Firmata declarations are irrelevant when native hardware wins;
// selection must neither fail nor initialize either ESP32 provider.
controllers, err := resolveRoverHardwareControllers(cfg, manager, discardLogger(), factories)
if err != nil {
t.Fatalf("resolve native precedence: %v", err)
}
if controllers.CameraServo != nativeCamera || controllers.Headlight != nativeToggles["headlight"] || controllers.Laser != nativeToggles["laser"] {
t.Fatal("resolver did not retain native controllers")
}
messages := controllers.StartupBroadcasts()
if len(messages) != 2 || messages[0] != "Ignored ESP32 camera servo, headlight, laser because native GPIO is enabled." || messages[1] != "Rover hardware ready: camera servo via native GPIO, headlight via native GPIO, laser via native GPIO." {
t.Fatalf("native precedence broadcasts = %#v", messages)
}
}
type testCameraServoController struct {
cfg CameraServoConfig
}
func (controller *testCameraServoController) SetAngle(float64) error { return nil }
func (controller *testCameraServoController) Nudge(float64) error { return nil }
func (controller *testCameraServoController) SetPulseWidth(int) error { return nil }
func (controller *testCameraServoController) CurrentAngle() float64 { return 0 }
func (controller *testCameraServoController) Configuration() CameraServoConfig { return controller.cfg }
func (controller *testCameraServoController) BackendDescription() string { return "native GPIO" }
func (controller *testCameraServoController) Close() {}
type testToggleController struct {
cfg GPIOToggleConfig
on bool
}
func (controller *testToggleController) HandleAction(string) error { return nil }
func (controller *testToggleController) On() bool { return controller.on }
func (controller *testToggleController) Configuration() GPIOToggleConfig { return controller.cfg }
func (controller *testToggleController) BackendDescription() string { return "native GPIO" }
func (controller *testToggleController) Close() {}
+441
View File
@@ -0,0 +1,441 @@
package roverd
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"sync"
"testing"
"time"
)
func TestFirmataParserHandlesFragmentedSysex(t *testing.T) {
parser := FirmataParser{}
first, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription, 1})
if err != nil {
t.Fatalf("first fragment: %v", err)
}
if len(first) != 0 {
t.Fatalf("first fragment unexpectedly produced %d messages", len(first))
}
second, err := parser.Feed([]byte{0, 2, 0, firmataEndSysex})
if err != nil {
t.Fatalf("second fragment: %v", err)
}
want := []FirmataMessage{{
Command: firmataPeripheralFeature,
Data: []byte{firmataPeripheralDescription, 1, 0, 2, 0},
Sysex: true,
}}
if !reflect.DeepEqual(second, want) {
t.Fatalf("messages = %#v, want %#v", second, want)
}
}
func TestFirmataParserReturnsSeveralMessagesFromOneRead(t *testing.T) {
parser := FirmataParser{}
messages, err := parser.Feed([]byte{
firmataReportVersion, 2, 5,
firmataStartSysex, firmataCapabilityReply, 0x01, 0x01, 0x7F, firmataEndSysex,
firmataSetDigitalPin, 18, 1,
})
if err != nil {
t.Fatalf("feed: %v", err)
}
if len(messages) != 3 {
t.Fatalf("got %d messages, want 3", len(messages))
}
if messages[0].Command != firmataReportVersion || messages[1].Command != firmataCapabilityReply || messages[2].Command != firmataSetDigitalPin {
t.Fatalf("commands were not preserved in wire order: %#v", messages)
}
}
func TestFirmataParserRejectsEightBitSysexDataAndRecovers(t *testing.T) {
parser := FirmataParser{}
if _, err := parser.Feed([]byte{firmataStartSysex, firmataPeripheralFeature, 0x80}); err == nil {
t.Fatal("expected invalid SysEx data to fail")
}
messages, err := parser.Feed([]byte{firmataReportVersion, 2, 5})
if err != nil {
t.Fatalf("feed after invalid SysEx: %v", err)
}
if len(messages) != 1 || messages[0].Command != firmataReportVersion {
t.Fatalf("parser did not recover: %#v", messages)
}
}
func TestFirmataSevenBitRoundTripIncludesUTF8(t *testing.T) {
raw := []byte(`{"name":"Café lights","value":255}`)
encoded := EncodeFirmata7Bit(raw)
for index, value := range encoded {
if value&0x80 != 0 {
t.Fatalf("encoded byte %d is not seven-bit clean: 0x%02x", index, value)
}
}
decoded, err := DecodeFirmata7Bit(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !bytes.Equal(decoded, raw) {
t.Fatalf("decoded %q, want %q", decoded, raw)
}
}
func TestDecodeFirmataSevenBitRejectsMalformedPairs(t *testing.T) {
for name, encoded := range map[string][]byte{
"odd length": {1},
"high byte": {1, 2},
"eight bit": {0x80, 0},
} {
t.Run(name, func(t *testing.T) {
if _, err := DecodeFirmata7Bit(encoded); err == nil {
t.Fatal("expected malformed pair to fail")
}
})
}
}
func TestPeripheralDescriptionPreservesControlOrder(t *testing.T) {
raw := []byte(`{
"name":"Test peripheral",
"controls":[
{"id":"servo","type":"slider","name":"Servo","min":0,"max":180,"output":{"type":"servo","pin":14}},
{"id":"lights","type":"slider","name":"Lights","min":0,"max":255,"output":{"type":"pwm","pin":18}},
{"id":"action","type":"button","name":"Action","mode":"momentary","output":{"type":"custom"}}
]
}`)
var description PeripheralDescription
if err := json.Unmarshal(raw, &description); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if err := description.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
want := []string{"servo", "lights", "action"}
for index, id := range want {
if description.Controls[index].ID != id {
t.Fatalf("control %d = %q, want %q", index, description.Controls[index].ID, id)
}
}
}
func TestPeripheralDescriptionRejectsInvalidDeclarations(t *testing.T) {
minimum, maximum, pin := 10, 1, 200
for name, description := range map[string]PeripheralDescription{
"duplicate id": {
Name: "device",
Controls: []PeripheralControl{
{ID: "same", Name: "First", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
{ID: "same", Name: "Second", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "custom"}},
},
},
"reversed range": {
Name: "device",
Controls: []PeripheralControl{{
ID: "level", Name: "Level", Type: "slider", Minimum: &minimum, Maximum: &maximum, Output: PeripheralOutput{Type: "custom"},
}},
},
"pin outside Firmata": {
Name: "device",
Controls: []PeripheralControl{{
ID: "switch", Name: "Switch", Type: "button", Mode: "toggle", Output: PeripheralOutput{Type: "digital", Pin: &pin},
}},
},
} {
t.Run(name, func(t *testing.T) {
if err := description.Validate(); err == nil {
t.Fatal("expected invalid description to fail")
}
})
}
}
func TestParseFirmataCapabilities(t *testing.T) {
pins, err := parseFirmataCapabilities([]byte{
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
})
if err != nil {
t.Fatalf("parse capabilities: %v", err)
}
if len(pins) != 2 || len(pins[0]) != 2 || pins[1][1].Mode != FirmataPinModeServo {
t.Fatalf("unexpected capabilities: %#v", pins)
}
if _, err := parseFirmataCapabilities([]byte{FirmataPinModeOutput}); err == nil {
t.Fatal("expected incomplete capability pair to fail")
}
}
func TestFirmataClientWritesStandardCommands(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SetPinMode(14, FirmataPinModeServo); err != nil {
t.Fatalf("set pin mode: %v", err)
}
if err := client.ConfigureServo(14, 900, 2100); err != nil {
t.Fatalf("configure servo: %v", err)
}
if err := client.ExtendedAnalog(14, 180); err != nil {
t.Fatalf("extended analog: %v", err)
}
if err := client.SetDigitalPin(19, true); err != nil {
t.Fatalf("digital write: %v", err)
}
want := []byte{
firmataSetPinMode, 14, FirmataPinModeServo,
firmataStartSysex, firmataServoConfig, 14, 4, 7, 52, 16, firmataEndSysex,
firmataStartSysex, firmataExtendedAnalog, 14, 52, 1, firmataEndSysex,
firmataSetDigitalPin, 19, 1,
}
if got := connection.Bytes(); !bytes.Equal(got, want) {
t.Fatalf("wire bytes = %v, want %v", got, want)
}
}
func TestFirmataClientQueriesAndDecodesDescription(t *testing.T) {
descriptionJSON := []byte(`{"name":"Bench device","controls":[{"id":"go","type":"button","name":"Go","mode":"momentary","output":{"type":"custom"}}]}`)
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
description := append([]byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}, EncodeFirmata7Bit(descriptionJSON)...)
description = append(description, firmataEndSysex)
connection := newScriptedConnection(
append(append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...), firmataEndSysex),
description,
)
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
firmware, err := client.QueryFirmware(ctx)
if err != nil {
t.Fatalf("query firmware: %v", err)
}
if firmware.Name != "RoverPeripheralFirmata" || firmware.Major != 1 || firmware.Minor != 0 {
t.Fatalf("unexpected firmware: %#v", firmware)
}
got, err := client.Describe(ctx)
if err != nil {
t.Fatalf("describe: %v", err)
}
if got.Name != "Bench device" || len(got.Controls) != 1 || got.Controls[0].ID != "go" {
t.Fatalf("unexpected description: %#v", got)
}
writes := connection.Bytes()
wantWrites := []byte{
firmataStartSysex, firmataReportFirmware, firmataEndSysex,
firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescribe, firmataEndSysex,
}
if !bytes.Equal(writes, wantWrites) {
t.Fatalf("queries = %v, want %v", writes, wantWrites)
}
}
func TestFirmataClientKeepsReadingAfterSerialTimeoutEOF(t *testing.T) {
firmwareName := EncodeFirmata7Bit([]byte("RoverPeripheralFirmata"))
response := append([]byte{firmataStartSysex, firmataReportFirmware, 1, 0}, firmwareName...)
response = append(response, firmataEndSysex)
// tarm/serial returns io.EOF when its ReadTimeout expires without bytes.
// Reproducing that behavior before the response prevents this regression
// from being hidden by an in-memory reader that blocks indefinitely instead.
connection := newScriptedConnection(response)
connection.timeoutsBeforeRead = 1
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
firmware, err := client.QueryFirmware(ctx)
if err != nil {
t.Fatalf("query firmware after timeout: %v", err)
}
if firmware.Name != "RoverPeripheralFirmata" {
t.Fatalf("firmware name = %q", firmware.Name)
}
}
func TestFirmataClientEncodesCustomControl(t *testing.T) {
for name, testCase := range map[string]struct {
controlID string
value any
wantJSON string
}{
"button": {controlID: "specialAction", value: true, wantJSON: `{"control":"specialAction","value":true}`},
"text": {controlID: "displayText", value: "Café ready", wantJSON: `{"control":"displayText","value":"Café ready"}`},
} {
t.Run(name, func(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SendPeripheralControl(testCase.controlID, testCase.value); err != nil {
t.Fatalf("send control: %v", err)
}
wire := connection.Bytes()
if len(wire) < 5 || wire[0] != firmataStartSysex || wire[1] != firmataPeripheralFeature || wire[2] != firmataPeripheralControl || wire[len(wire)-1] != firmataEndSysex {
t.Fatalf("invalid control frame: %v", wire)
}
raw, err := DecodeFirmata7Bit(wire[3 : len(wire)-1])
if err != nil {
t.Fatalf("decode control: %v", err)
}
if string(raw) != testCase.wantJSON {
t.Fatalf("control JSON = %s, want %s", raw, testCase.wantJSON)
}
})
}
}
func TestFirmataClientQueriesCapabilities(t *testing.T) {
response := []byte{
firmataStartSysex, firmataCapabilityReply,
FirmataPinModeOutput, 1, FirmataPinModePWM, 8, 0x7F,
FirmataPinModeOutput, 1, FirmataPinModeServo, 14, 0x7F,
firmataEndSysex,
}
connection := newScriptedConnection(response)
client := NewFirmataClient(connection)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client.Start(ctx)
pins, err := client.QueryCapabilities(ctx)
if err != nil {
t.Fatalf("query capabilities: %v", err)
}
if len(pins) != 2 || pins[0][1].Mode != FirmataPinModePWM || pins[1][1].Mode != FirmataPinModeServo {
t.Fatalf("unexpected capabilities: %#v", pins)
}
if want := []byte{firmataStartSysex, firmataCapabilityQuery, firmataEndSysex}; !bytes.Equal(connection.Bytes(), want) {
t.Fatalf("query bytes = %v, want %v", connection.Bytes(), want)
}
}
func TestFirmataClientRejectsControlTooLargeForFirmwareParser(t *testing.T) {
connection := &recordingConnection{}
client := NewFirmataClient(connection)
if err := client.SendPeripheralControl("displayText", string(bytes.Repeat([]byte{'x'}, 200))); err == nil {
t.Fatal("expected oversized control to fail")
}
if len(connection.Bytes()) != 0 {
t.Fatalf("oversized control wrote bytes: %v", connection.Bytes())
}
}
// recordingConnection is deliberately minimal: write-focused tests should not
// need goroutines or a real serial device merely to inspect exact Firmata bytes.
type recordingConnection struct {
mu sync.Mutex
writes bytes.Buffer
closed bool
writeErr error
}
func (connection *recordingConnection) Read(_ []byte) (int, error) { return 0, io.EOF }
func (connection *recordingConnection) Write(data []byte) (int, error) {
connection.mu.Lock()
defer connection.mu.Unlock()
if connection.closed {
return 0, io.ErrClosedPipe
}
if connection.writeErr != nil {
return 0, connection.writeErr
}
return connection.writes.Write(data)
}
func (connection *recordingConnection) Close() error {
connection.mu.Lock()
defer connection.mu.Unlock()
connection.closed = true
return nil
}
func (connection *recordingConnection) Bytes() []byte {
connection.mu.Lock()
defer connection.mu.Unlock()
return append([]byte(nil), connection.writes.Bytes()...)
}
func (connection *recordingConnection) Closed() bool {
connection.mu.Lock()
defer connection.mu.Unlock()
return connection.closed
}
func (connection *recordingConnection) SetWriteError(err error) {
connection.mu.Lock()
defer connection.mu.Unlock()
connection.writeErr = err
}
// scriptedConnection releases one response after each client write. This
// mirrors request/response serial behavior and prevents a fast reader goroutine
// from publishing all scripted answers before the matching query is sent.
type scriptedConnection struct {
recordingConnection
responses chan []byte
reads chan []byte
timeoutsBeforeRead int
pendingRead []byte
closeOnce sync.Once
}
func newScriptedConnection(responses ...[]byte) *scriptedConnection {
connection := &scriptedConnection{
responses: make(chan []byte, len(responses)),
reads: make(chan []byte, len(responses)),
}
for _, response := range responses {
connection.responses <- append([]byte(nil), response...)
}
return connection
}
func (connection *scriptedConnection) Read(target []byte) (int, error) {
if connection.timeoutsBeforeRead > 0 {
connection.timeoutsBeforeRead--
return 0, io.EOF
}
if len(connection.pendingRead) == 0 {
response, ok := <-connection.reads
if !ok {
return 0, io.ErrClosedPipe
}
connection.pendingRead = response
}
written := copy(target, connection.pendingRead)
connection.pendingRead = connection.pendingRead[written:]
return written, nil
}
func (connection *scriptedConnection) Write(data []byte) (int, error) {
written, err := connection.recordingConnection.Write(data)
if err == nil {
select {
case response := <-connection.responses:
connection.reads <- response
default:
}
}
return written, err
}
func (connection *scriptedConnection) Close() error {
connection.closeOnce.Do(func() {
_ = connection.recordingConnection.Close()
close(connection.reads)
})
return nil
}
+9
View File
@@ -87,6 +87,15 @@ func (g *GPIOToggle) On() bool {
return g.on
}
// Configuration returns the native toggle behavior used in the rover hello.
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return g.cfg
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
func (g *GPIOToggle) setLocked(on bool) error {
// This is the only place a logical device state becomes an electrical GPIO
// value. Hardware that turns on when pulled low sets activeLow in roverd
+12 -4
View File
@@ -13,10 +13,10 @@ type GPIOToggle struct {
func NewGPIOToggle(name string, _ GPIOToggleConfig, _ *log.Logger) (*GPIOToggle, error) {
/*
A Debian laptop has no Raspberry Pi GPIO character-device contract for
headlights or lasers. Returning an error when enabled makes bad laptop
configs fail during startup instead of advertising controls that cannot
change any hardware.
A Debian laptop has no native Raspberry Pi GPIO contract. Returning an
error here catches an invalid native configuration; the shared resolver
selects an ESP32 Firmata toggle before this constructor when native GPIO
is disabled.
*/
return nil, fmt.Errorf("%s not supported in the debian-laptop build", name)
}
@@ -30,3 +30,11 @@ func (g *GPIOToggle) HandleAction(action string) error {
func (g *GPIOToggle) On() bool {
return false
}
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+8
View File
@@ -24,3 +24,11 @@ func (g *GPIOToggle) HandleAction(action string) error {
func (g *GPIOToggle) On() bool {
return false
}
func (g *GPIOToggle) Configuration() GPIOToggleConfig {
return GPIOToggleConfig{}
}
func (g *GPIOToggle) BackendDescription() string {
return "native GPIO"
}
+164
View File
@@ -0,0 +1,164 @@
package roverd
import (
"fmt"
"log"
"strings"
"time"
)
// Both physical servo backends consume these exact motion constants. Keeping
// them in shared code prevents Pi PWM and ESP32 Firmata movement from drifting
// apart as either implementation evolves.
const (
maxServoDegPerSec = 60.0
servoStepInterval = 20 * time.Millisecond
servoAngleEpsilon = 0.01
)
// CameraServoController is the hardware-neutral camera-tilt contract used by
// WSClient. Native Pi PWM and ESP32 Firmata implementations expose identical
// logical behavior, so command handling never branches on the rover host type.
type CameraServoController interface {
SetAngle(angle float64) error
Nudge(delta float64) error
SetPulseWidth(micros int) error
CurrentAngle() float64
Configuration() CameraServoConfig
BackendDescription() string
Close()
}
// ToggleController keeps headlight and laser command/state behavior independent
// of whether the electrical write happens on native Pi GPIO or an ESP32 pin.
type ToggleController interface {
HandleAction(action string) error
On() bool
Configuration() GPIOToggleConfig
BackendDescription() string
Close()
}
// RoverHardwareControllers is the result of the single startup-time backend
// decision. Its effective configurations are derived from whichever backend
// won, making the normal rover hello accurate on both Pi and laptop hosts.
type RoverHardwareControllers struct {
CameraServo CameraServoController
Headlight ToggleController
Laser ToggleController
ignoredESP32Roles []string
}
// StartupBroadcasts returns short operator-facing messages. Detailed pin and
// protocol information remains in the journal; tty1 only explains which
// physical backend won and whether an advertised ESP32 role was ignored.
func (controllers RoverHardwareControllers) StartupBroadcasts() []string {
var messages []string
if len(controllers.ignoredESP32Roles) > 0 {
messages = append(messages, fmt.Sprintf(
"Ignored ESP32 %s because native GPIO is enabled.",
strings.Join(controllers.ignoredESP32Roles, ", "),
))
}
messages = append(messages, fmt.Sprintf(
"Rover hardware ready: camera servo via %s, headlight via %s, laser via %s.",
controllerBackend(controllers.CameraServo),
controllerBackend(controllers.Headlight),
controllerBackend(controllers.Laser),
))
return messages
}
func controllerBackend(controller interface{ BackendDescription() string }) string {
if controller == nil {
return "disabled"
}
return controller.BackendDescription()
}
type nativeHardwareControllerFactories struct {
newCameraServo func(CameraServoConfig, *log.Logger) (CameraServoController, error)
newToggle func(string, GPIOToggleConfig, *log.Logger) (ToggleController, error)
}
// ResolveRoverHardwareControllers applies one rule on every real rover build:
// enabled native GPIO wins, otherwise one discovered ESP32 may fill the role.
// The rule is intentionally not selected by GOARCH or the debian_laptop tag.
func ResolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger) (RoverHardwareControllers, error) {
factories := nativeHardwareControllerFactories{
newCameraServo: func(config CameraServoConfig, logger *log.Logger) (CameraServoController, error) {
return NewCameraServo(config, logger)
},
newToggle: func(name string, config GPIOToggleConfig, logger *log.Logger) (ToggleController, error) {
return NewGPIOToggle(name, config, logger)
},
}
return resolveRoverHardwareControllers(cfg, peripherals, logger, factories)
}
func resolveRoverHardwareControllers(cfg *Config, peripherals *PeripheralManager, logger *log.Logger, factories nativeHardwareControllerFactories) (RoverHardwareControllers, error) {
var controllers RoverHardwareControllers
var err error
// Record ignored declarations separately from selecting controllers so the
// same native-first decision can be explained on the local rover console.
if cfg.CameraServo.Enabled && peripherals.HasRoverRole("cameraServo") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "camera servo")
}
if cfg.Headlight.Enabled && peripherals.HasRoverRole("headlight") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "headlight")
}
if cfg.Laser.Enabled && peripherals.HasRoverRole("laser") {
controllers.ignoredESP32Roles = append(controllers.ignoredESP32Roles, "laser")
}
controllers.CameraServo, err = resolveCameraServoController(cfg.CameraServo, peripherals, logger, factories.newCameraServo)
if err != nil {
return RoverHardwareControllers{}, fmt.Errorf("init camera servo: %w", err)
}
controllers.Headlight, err = resolveToggleController("headlight", cfg.Headlight, peripherals, logger, factories.newToggle)
if err != nil {
controllers.Close()
return RoverHardwareControllers{}, fmt.Errorf("init headlight: %w", err)
}
controllers.Laser, err = resolveToggleController("laser", cfg.Laser, peripherals, logger, factories.newToggle)
if err != nil {
controllers.Close()
return RoverHardwareControllers{}, fmt.Errorf("init laser: %w", err)
}
return controllers, nil
}
func resolveCameraServoController(nativeConfig CameraServoConfig, peripherals *PeripheralManager, logger *log.Logger, newNative func(CameraServoConfig, *log.Logger) (CameraServoController, error)) (CameraServoController, error) {
if nativeConfig.Enabled {
if peripherals.HasRoverRole("cameraServo") {
logger.Printf("ignoring ESP32 cameraServo because native camera servo is enabled")
}
return newNative(nativeConfig, logger)
}
return peripherals.NewFirmataCameraServo(logger)
}
func resolveToggleController(name string, nativeConfig GPIOToggleConfig, peripherals *PeripheralManager, logger *log.Logger, newNative func(string, GPIOToggleConfig, *log.Logger) (ToggleController, error)) (ToggleController, error) {
if nativeConfig.Enabled {
if peripherals.HasRoverRole(name) {
logger.Printf("ignoring ESP32 %s because native %s is enabled", name, name)
}
return newNative(name, nativeConfig, logger)
}
return peripherals.NewFirmataToggle(name, logger)
}
// Close releases selected controller resources in reverse dependency order.
// Firmata controllers do not close the shared serial connection; that remains
// owned by PeripheralManager and is released by its separate shutdown defer.
func (controllers *RoverHardwareControllers) Close() {
if controllers.Laser != nil {
controllers.Laser.Close()
}
if controllers.Headlight != nil {
controllers.Headlight.Close()
}
if controllers.CameraServo != nil {
controllers.CameraServo.Close()
}
}
+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)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package roverd
import (
"encoding/json"
"strings"
"testing"
)
func TestHelloPeripheralMetadataContainsOnlyRenderableFields(t *testing.T) {
minimum, maximum := 0, 180
message := helloMessage{
Type: "hello",
Name: "test-rover",
Peripherals: []RoverPeripheralMetadata{{
ID: "firmata-0",
Name: "Camera arm",
Controls: []RoverPeripheralControl{{
ID: "position", Type: "slider", Name: "Position", Minimum: &minimum, Maximum: &maximum,
}},
}},
}
encoded, err := json.Marshal(message)
if err != nil {
t.Fatalf("marshal hello: %v", err)
}
text := string(encoded)
if !strings.Contains(text, `"peripherals":[{"id":"firmata-0","name":"Camera arm","controls":[{"id":"position","type":"slider","name":"Position","min":0,"max":180}]`) {
t.Fatalf("hello is missing ordered peripheral metadata: %s", text)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(encoded, &envelope); err != nil {
t.Fatalf("unmarshal hello envelope: %v", err)
}
peripheralJSON := string(envelope["peripherals"])
if strings.Contains(peripheralJSON, `"pin"`) || strings.Contains(peripheralJSON, `"output"`) {
t.Fatalf("hello exposed private Firmata routing: %s", peripheralJSON)
}
}
func TestInboundPeripheralCommandPreservesRawJSONValue(t *testing.T) {
var message inboundMessage
err := json.Unmarshal([]byte(`{
"type":"peripheral",
"id":"command-1",
"peripheral":{"id":"firmata-0","control":"displayText","value":"hello rover"}
}`), &message)
if err != nil {
t.Fatalf("unmarshal command: %v", err)
}
if message.Peripheral == nil || message.Peripheral.ID != "firmata-0" || message.Peripheral.Control != "displayText" {
t.Fatalf("unexpected peripheral command: %#v", message.Peripheral)
}
if string(message.Peripheral.Value) != `"hello rover"` {
t.Fatalf("raw value = %s", message.Peripheral.Value)
}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build dummy
package roverd
import (
"context"
"io"
"log"
"time"
)
// DiscoverPeripheralManager remains inert in a dummy build. The dummy daemon is
// specifically used without rover hardware and must not probe or reset serial
// devices that happen to be attached to a developer's machine.
func DiscoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger) (*PeripheralManager, error) {
dependencies := peripheralDiscoveryDependencies{
listCandidates: func(string) ([]string, error) { return nil, nil },
open: func(string) (io.ReadWriteCloser, error) { return nil, nil },
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: 0,
}
return discoverPeripheralManager(ctx, excludedDevice, logger, dependencies)
}
+38
View File
@@ -0,0 +1,38 @@
//go:build !dummy
package roverd
import (
"context"
"io"
"log"
"time"
"github.com/tarm/serial"
)
const (
peripheralBaud = 115200
peripheralReadTimeout = 100 * time.Millisecond
)
// DiscoverPeripheralManager performs the one and only peripheral scan for this
// roverd process. The Roomba Open Interface serial device is explicitly
// excluded because it belongs to SerialAdapter and must never be probed as an
// ESP32 peripheral.
func DiscoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger) (*PeripheralManager, error) {
dependencies := peripheralDiscoveryDependencies{
listCandidates: listPeripheralCandidates,
open: func(devicePath string) (io.ReadWriteCloser, error) {
return serial.OpenPort(&serial.Config{
Name: devicePath,
Baud: peripheralBaud,
ReadTimeout: peripheralReadTimeout,
})
},
sleep: time.Sleep,
startupWait: peripheralStartupWait,
handshakeWait: peripheralHandshakeTimeout,
}
return discoverPeripheralManager(ctx, excludedDevice, logger, dependencies)
}
+584
View File
@@ -0,0 +1,584 @@
package roverd
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
peripheralStartupWait = 2 * time.Second
peripheralHandshakeTimeout = 5 * time.Second
peripheralFirmwareName = "RoverPeripheralFirmata"
)
// RoverPeripheralMetadata is the part of a peripheral description that leaves
// roverd. Pin numbers and output mappings intentionally remain private to the
// rover process; the server and browser identify only the declared control.
type RoverPeripheralMetadata struct {
ID string `json:"id"`
Name string `json:"name"`
Controls []RoverPeripheralControl `json:"controls"`
}
// RoverPeripheralControl contains only fields needed to render and operate one
// of the four generic UI controls. Pointer fields preserve legitimate zero
// bounds while still omitting properties that do not apply to a control type.
type RoverPeripheralControl struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Mode string `json:"mode,omitempty"`
Minimum *int `json:"min,omitempty"`
Maximum *int `json:"max,omitempty"`
MaximumLength *int `json:"maxLength,omitempty"`
}
type managedPeripheral struct {
metadata RoverPeripheralMetadata
description PeripheralDescription
controls map[string]PeripheralControl
client *FirmataClient
connection io.ReadWriteCloser
devicePath string
capabilities [][]FirmataPinCapability
}
// PeripheralManager owns the immutable boot-time inventory and every serial
// connection behind it. The inventory never changes after discovery, even if a
// USB device later disappears; a process restart is the only rescan mechanism.
type PeripheralManager struct {
mu sync.RWMutex
peripherals []*managedPeripheral
byID map[string]*managedPeripheral
cancel context.CancelFunc
closeOnce sync.Once
logger *log.Logger
failures chan PeripheralFailure
}
// PeripheralFailure is emitted once when a successfully discovered device's
// serial reader terminates unexpectedly. Device identity is retained even
// though reconnection still requires restarting roverd.
type PeripheralFailure struct {
ID string
Name string
Err error
}
type peripheralDiscoveryDependencies struct {
listCandidates func(excludedDevice string) ([]string, error)
open func(devicePath string) (io.ReadWriteCloser, error)
sleep func(time.Duration)
startupWait time.Duration
handshakeWait time.Duration
}
func discoverPeripheralManager(ctx context.Context, excludedDevice string, logger *log.Logger, dependencies peripheralDiscoveryDependencies) (*PeripheralManager, error) {
managerContext, cancel := context.WithCancel(ctx)
manager := &PeripheralManager{
byID: make(map[string]*managedPeripheral),
cancel: cancel,
logger: logger,
failures: make(chan PeripheralFailure, 16),
}
candidates, err := dependencies.listCandidates(excludedDevice)
if err != nil {
manager.Close()
return nil, fmt.Errorf("list peripheral serial devices: %w", err)
}
for _, devicePath := range candidates {
connection, err := dependencies.open(devicePath)
if err != nil {
logger.Printf("skipping peripheral candidate %s: open failed: %v", devicePath, err)
continue
}
// UART bridge and native-USB development boards may reset when opened.
// Waiting and then draining boot fragments gives the handshake a fresh
// parser boundary instead of occasionally starting inside an old SysEx.
dependencies.sleep(dependencies.startupWait)
if err := drainPeripheralSerial(connection); err != nil {
connection.Close()
logger.Printf("skipping peripheral candidate %s: drain failed: %v", devicePath, err)
continue
}
client := NewFirmataClient(connection)
client.Start(managerContext)
firmware, err := queryPeripheralFirmware(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
logger.Printf("skipping peripheral candidate %s: Firmata query failed: %v", devicePath, err)
continue
}
if firmware.Name != peripheralFirmwareName {
connection.Close()
logger.Printf("skipping Firmata device %s: firmware %q does not expose rover peripherals", devicePath, firmware.Name)
continue
}
capabilities, err := queryPeripheralCapabilities(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("query capabilities from rover peripheral %s: %w", devicePath, err)
}
description, err := queryPeripheralDescription(managerContext, client, dependencies.handshakeWait)
if err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("describe rover peripheral %s: %w", devicePath, err)
}
peripheral := newManagedPeripheral(len(manager.peripherals), devicePath, connection, client, description, capabilities)
if err := peripheral.initializeStandardOutputs(); err != nil {
connection.Close()
manager.Close()
return nil, fmt.Errorf("initialize rover peripheral %s: %w", devicePath, err)
}
manager.peripherals = append(manager.peripherals, peripheral)
manager.byID[peripheral.metadata.ID] = peripheral
client.SetTerminalErrorHandler(func(terminalErr error) {
failure := PeripheralFailure{ID: peripheral.metadata.ID, Name: peripheral.metadata.Name, Err: terminalErr}
select {
case manager.failures <- failure:
default:
// The channel is intentionally bounded because broadcasts are
// diagnostic. Never block a Firmata reader during a fleet-wide
// shutdown or an unlikely burst of simultaneous USB failures.
logger.Printf("peripheral failure notification queue full for %s: %v", peripheral.metadata.ID, terminalErr)
}
})
logger.Printf("discovered rover peripheral %s on %s with %d generic controls", description.Name, devicePath, len(description.Controls))
}
return manager, nil
}
// StartupBroadcasts describes the fixed inventory without exposing device
// paths or wiring details on the rover's local console.
func (manager *PeripheralManager) StartupBroadcasts() []string {
inventory := manager.Inventory()
if len(inventory) == 0 {
return []string{"No ESP32 rover peripherals detected during startup."}
}
messages := make([]string, 0, len(inventory))
for _, peripheral := range inventory {
messages = append(messages, fmt.Sprintf(
"Rover peripheral %q connected as %s with %d additional controls.",
peripheral.Name,
peripheral.ID,
len(peripheral.Controls),
))
}
return messages
}
// Failures exposes unexpected runtime disconnects to the daemon entry point,
// which owns the ConsoleNotifier and therefore owns user-facing wording.
func (manager *PeripheralManager) Failures() <-chan PeripheralFailure {
if manager == nil {
return nil
}
return manager.failures
}
func listPeripheralCandidates(excludedDevice string) ([]string, error) {
patterns := []string{
"/dev/serial/by-id/*",
"/dev/ttyUSB*",
"/dev/ttyACM*",
}
var matchesInPreferenceOrder []string
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
sort.Strings(matches)
matchesInPreferenceOrder = append(matchesInPreferenceOrder, matches...)
}
return uniquePeripheralCandidates(matchesInPreferenceOrder, excludedDevice), nil
}
func uniquePeripheralCandidates(matches []string, excludedDevice string) []string {
excludedCanonical := canonicalDevicePath(excludedDevice)
seen := make(map[string]struct{})
var candidates []string
for _, match := range matches {
canonical := canonicalDevicePath(match)
if canonical == excludedCanonical {
continue
}
if _, exists := seen[canonical]; exists {
continue
}
seen[canonical] = struct{}{}
// /dev/serial/by-id matches are passed first, so retaining the first
// spelling favors stable names while still removing each tty alias.
candidates = append(candidates, match)
}
return candidates
}
func canonicalDevicePath(devicePath string) string {
if devicePath == "" {
return ""
}
resolved, err := filepath.EvalSymlinks(devicePath)
if err == nil {
return resolved
}
abs, err := filepath.Abs(devicePath)
if err == nil {
return filepath.Clean(abs)
}
return filepath.Clean(devicePath)
}
func drainPeripheralSerial(connection io.Reader) error {
buffer := make([]byte, 256)
for {
_, err := connection.Read(buffer)
if errors.Is(err, io.EOF) {
// tarm/serial uses EOF to mean its short read timeout elapsed. That
// quiet interval is precisely the boundary needed before handshaking.
return nil
}
if err != nil {
return err
}
}
}
func queryPeripheralFirmware(ctx context.Context, client *FirmataClient, timeout time.Duration) (FirmataFirmware, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.QueryFirmware(queryContext)
}
func queryPeripheralCapabilities(ctx context.Context, client *FirmataClient, timeout time.Duration) ([][]FirmataPinCapability, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.QueryCapabilities(queryContext)
}
func queryPeripheralDescription(ctx context.Context, client *FirmataClient, timeout time.Duration) (PeripheralDescription, error) {
queryContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return client.Describe(queryContext)
}
func newManagedPeripheral(index int, devicePath string, connection io.ReadWriteCloser, client *FirmataClient, description PeripheralDescription, capabilities [][]FirmataPinCapability) *managedPeripheral {
controls := make(map[string]PeripheralControl, len(description.Controls))
metadataControls := make([]RoverPeripheralControl, 0, len(description.Controls))
for _, control := range description.Controls {
controls[control.ID] = control
metadataControls = append(metadataControls, RoverPeripheralControl{
ID: control.ID,
Type: control.Type,
Name: control.Name,
Mode: control.Mode,
Minimum: cloneIntPointer(control.Minimum),
Maximum: cloneIntPointer(control.Maximum),
MaximumLength: cloneIntPointer(control.MaximumLength),
})
}
return &managedPeripheral{
metadata: RoverPeripheralMetadata{
ID: fmt.Sprintf("firmata-%d", index),
Name: description.Name,
Controls: metadataControls,
},
description: description,
controls: controls,
client: client,
connection: connection,
devicePath: devicePath,
capabilities: capabilities,
}
}
func cloneIntPointer(value *int) *int {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func (peripheral *managedPeripheral) initializeStandardOutputs() error {
if camera := peripheral.description.RoverControls.CameraServo; camera != nil {
if err := peripheral.requirePinMode("cameraServo", camera.Pin, FirmataPinModeServo); err != nil {
return err
}
}
if headlight := peripheral.description.RoverControls.Headlight; headlight != nil {
if err := peripheral.requirePinMode("headlight", headlight.Pin, FirmataPinModeOutput); err != nil {
return err
}
}
if laser := peripheral.description.RoverControls.Laser; laser != nil {
if err := peripheral.requirePinMode("laser", laser.Pin, FirmataPinModeOutput); err != nil {
return err
}
}
for _, control := range peripheral.description.Controls {
if control.Output.Type == "custom" {
continue
}
pin := byte(*control.Output.Pin)
requiredMode := FirmataPinModeOutput
if control.Output.Type == "pwm" {
requiredMode = FirmataPinModePWM
} else if control.Output.Type == "servo" {
requiredMode = FirmataPinModeServo
}
if err := peripheral.requirePinMode("control "+control.ID, int(pin), requiredMode); err != nil {
return err
}
switch control.Output.Type {
case "digital":
if err := peripheral.client.SetPinMode(pin, FirmataPinModeOutput); err != nil {
return fmt.Errorf("configure control %q as digital: %w", control.ID, err)
}
// A generic button begins logically off. Active-low hardware needs a
// high electrical level to represent that same initial state.
if err := peripheral.client.SetDigitalPin(pin, control.Output.ActiveLow); err != nil {
return fmt.Errorf("initialize digital control %q: %w", control.ID, err)
}
case "pwm":
if err := peripheral.client.SetPinMode(pin, FirmataPinModePWM); err != nil {
return fmt.Errorf("configure control %q as PWM: %w", control.ID, err)
}
case "servo":
if err := peripheral.client.SetPinMode(pin, FirmataPinModeServo); err != nil {
return fmt.Errorf("configure control %q as servo: %w", control.ID, err)
}
}
}
return nil
}
func (peripheral *managedPeripheral) requirePinMode(owner string, pin int, requiredMode byte) error {
if pin < 0 || pin >= len(peripheral.capabilities) {
return fmt.Errorf("%s advertises pin %d, but Firmata reported only %d pins", owner, pin, len(peripheral.capabilities))
}
for _, capability := range peripheral.capabilities[pin] {
if capability.Mode == requiredMode {
return nil
}
}
return fmt.Errorf("%s advertises pin %d without required Firmata mode 0x%02x", owner, pin, requiredMode)
}
// HasRoverRole reports whether discovery found an ESP32 implementation of one
// established rover control. It is used only for startup selection and logging;
// commands continue to target the selected controller interface directly.
func (manager *PeripheralManager) HasRoverRole(role string) bool {
return len(manager.roverRoleProviders(role)) > 0
}
func (manager *PeripheralManager) roverRoleProviders(role string) []*managedPeripheral {
if manager == nil {
return nil
}
manager.mu.RLock()
defer manager.mu.RUnlock()
var providers []*managedPeripheral
for _, peripheral := range manager.peripherals {
switch role {
case "cameraServo":
if peripheral.description.RoverControls.CameraServo != nil {
providers = append(providers, peripheral)
}
case "headlight":
if peripheral.description.RoverControls.Headlight != nil {
providers = append(providers, peripheral)
}
case "laser":
if peripheral.description.RoverControls.Laser != nil {
providers = append(providers, peripheral)
}
}
}
return providers
}
// NewFirmataCameraServo constructs the shared camera controller only when a
// discovered peripheral declared that standardized role. Absence is a normal
// disabled-feature result rather than an error.
func (manager *PeripheralManager) NewFirmataCameraServo(logger *log.Logger) (CameraServoController, error) {
providers := manager.roverRoleProviders("cameraServo")
if len(providers) == 0 {
return nil, nil
}
if len(providers) > 1 {
return nil, duplicateRoverRoleError("cameraServo", providers)
}
peripheral := providers[0]
return newFirmataCameraServo(peripheral, *peripheral.description.RoverControls.CameraServo, logger)
}
// NewFirmataToggle resolves either standardized digital role without exposing
// the peripheral connection or ESP32 pin to WSClient.
func (manager *PeripheralManager) NewFirmataToggle(role string, logger *log.Logger) (ToggleController, error) {
providers := manager.roverRoleProviders(role)
if len(providers) == 0 {
return nil, nil
}
if len(providers) > 1 {
return nil, duplicateRoverRoleError(role, providers)
}
peripheral := providers[0]
var declaration *PeripheralDigitalRole
switch role {
case "headlight":
declaration = peripheral.description.RoverControls.Headlight
case "laser":
declaration = peripheral.description.RoverControls.Laser
default:
return nil, fmt.Errorf("unknown Firmata toggle role %q", role)
}
return newFirmataToggle(role, peripheral, *declaration, logger)
}
func duplicateRoverRoleError(role string, providers []*managedPeripheral) error {
providerIDs := make([]string, 0, len(providers))
for _, provider := range providers {
providerIDs = append(providerIDs, provider.metadata.ID)
}
return fmt.Errorf("rover peripheral role %s has multiple providers: %s", role, strings.Join(providerIDs, ", "))
}
// Inventory returns a defensive copy in startup order. Server reconnects reuse
// this same list and therefore never cause a USB rescan or ID reassignment.
func (manager *PeripheralManager) Inventory() []RoverPeripheralMetadata {
if manager == nil {
return nil
}
manager.mu.RLock()
defer manager.mu.RUnlock()
inventory := make([]RoverPeripheralMetadata, 0, len(manager.peripherals))
for _, peripheral := range manager.peripherals {
metadata := peripheral.metadata
metadata.Controls = make([]RoverPeripheralControl, 0, len(peripheral.metadata.Controls))
for _, control := range peripheral.metadata.Controls {
control.Minimum = cloneIntPointer(control.Minimum)
control.Maximum = cloneIntPointer(control.Maximum)
control.MaximumLength = cloneIntPointer(control.MaximumLength)
metadata.Controls = append(metadata.Controls, control)
}
inventory = append(inventory, metadata)
}
return inventory
}
// SetControl validates the browser-shaped value against the ESP32 declaration,
// then uses the private output mapping selected during startup. Neither the
// server nor browser can choose a pin or switch a custom control into raw GPIO.
func (manager *PeripheralManager) SetControl(peripheralID, controlID string, rawValue json.RawMessage) error {
if manager == nil {
return errors.New("rover peripherals disabled")
}
manager.mu.RLock()
peripheral := manager.byID[peripheralID]
manager.mu.RUnlock()
if peripheral == nil {
return fmt.Errorf("unknown peripheral %q", peripheralID)
}
control, exists := peripheral.controls[controlID]
if !exists {
return fmt.Errorf("unknown control %q on peripheral %q", controlID, peripheralID)
}
value, err := decodePeripheralControlValue(control, rawValue)
if err != nil {
return fmt.Errorf("control %q: %w", controlID, err)
}
switch control.Output.Type {
case "digital":
enabled := value.(bool)
if control.Output.ActiveLow {
enabled = !enabled
}
return peripheral.client.SetDigitalPin(byte(*control.Output.Pin), enabled)
case "pwm", "servo":
return peripheral.client.ExtendedAnalog(byte(*control.Output.Pin), value.(int))
case "custom":
return peripheral.client.SendPeripheralControl(control.ID, value)
default:
return fmt.Errorf("control has unsupported output %q", control.Output.Type)
}
}
func decodePeripheralControlValue(control PeripheralControl, rawValue json.RawMessage) (any, error) {
if len(rawValue) == 0 {
return nil, errors.New("value is required")
}
switch control.Type {
case "slider", "number":
var value int
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be a whole number")
}
if value < *control.Minimum || value > *control.Maximum {
return nil, fmt.Errorf("value must be between %d and %d", *control.Minimum, *control.Maximum)
}
return value, nil
case "button":
var value bool
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be true or false")
}
return value, nil
case "text":
var value string
if err := json.Unmarshal(rawValue, &value); err != nil {
return nil, errors.New("value must be text")
}
if utf8.RuneCountInString(value) > *control.MaximumLength {
return nil, fmt.Errorf("value must contain at most %d characters", *control.MaximumLength)
}
return value, nil
default:
return nil, fmt.Errorf("unsupported control type %q", control.Type)
}
}
// Close releases every discovered USB connection exactly once. It does not
// alter inventory or attempt to reconnect devices because shutdown/restart is
// the lifecycle boundary chosen for this feature.
func (manager *PeripheralManager) Close() {
if manager == nil {
return
}
manager.closeOnce.Do(func() {
manager.cancel()
manager.mu.Lock()
defer manager.mu.Unlock()
for _, peripheral := range manager.peripherals {
if err := peripheral.connection.Close(); err != nil {
manager.logger.Printf("close rover peripheral %s on %s: %v", peripheral.metadata.ID, peripheral.devicePath, err)
}
}
})
}
+425
View File
@@ -0,0 +1,425 @@
package roverd
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPeripheralManagerDiscoversInventoryAndDispatchesControls(t *testing.T) {
description := testPeripheralDescription("Bench accessory", false)
connection := scriptedPeripheralConnection(t, description)
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB9"},
map[string]*scriptedConnection{"/dev/ttyUSB9": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/ttyUSB0", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
inventory := manager.Inventory()
if len(inventory) != 1 {
t.Fatalf("inventory length = %d, want 1", len(inventory))
}
if inventory[0].ID != "firmata-0" || inventory[0].Name != "Bench accessory" {
t.Fatalf("unexpected peripheral metadata: %#v", inventory[0])
}
wantBroadcast := `Rover peripheral "Bench accessory" connected as firmata-0 with 3 additional controls.`
if broadcasts := manager.StartupBroadcasts(); len(broadcasts) != 1 || broadcasts[0] != wantBroadcast {
t.Fatalf("startup broadcasts = %#v, want %q", broadcasts, wantBroadcast)
}
wantOrder := []string{"servoPosition", "lightBrightness", "specialAction"}
for index, controlID := range wantOrder {
if inventory[0].Controls[index].ID != controlID {
t.Fatalf("control %d = %q, want %q", index, inventory[0].Controls[index].ID, controlID)
}
}
*inventory[0].Controls[0].Minimum = 99
if fresh := manager.Inventory(); *fresh[0].Controls[0].Minimum != 0 {
t.Fatal("caller mutation changed the manager's fixed inventory")
}
// Standard modes are configured once during discovery. Runtime slider
// commands should consequently contain only EXTENDED_ANALOG, not repeated
// mode changes that would detach and reattach a servo while it is moving.
baseline := len(connection.Bytes())
if err := manager.SetControl("firmata-0", "servoPosition", json.RawMessage(`90`)); err != nil {
t.Fatalf("set servo: %v", err)
}
servoWrite := connection.Bytes()[baseline:]
wantServo := []byte{firmataStartSysex, firmataExtendedAnalog, 13, 90, firmataEndSysex}
if !bytes.Equal(servoWrite, wantServo) {
t.Fatalf("servo bytes = %v, want %v", servoWrite, wantServo)
}
baseline = len(connection.Bytes())
if err := manager.SetControl("firmata-0", "lightBrightness", json.RawMessage(`128`)); err != nil {
t.Fatalf("set PWM: %v", err)
}
pwmWrite := connection.Bytes()[baseline:]
wantPWM := []byte{firmataStartSysex, firmataExtendedAnalog, 17, 0, 1, firmataEndSysex}
if !bytes.Equal(pwmWrite, wantPWM) {
t.Fatalf("PWM bytes = %v, want %v", pwmWrite, wantPWM)
}
baseline = len(connection.Bytes())
if err := manager.SetControl("firmata-0", "specialAction", json.RawMessage(`true`)); err != nil {
t.Fatalf("set custom button: %v", err)
}
customWrite := connection.Bytes()[baseline:]
if len(customWrite) < 5 || customWrite[1] != firmataPeripheralFeature || customWrite[2] != firmataPeripheralControl {
t.Fatalf("custom control did not use rover-peripheral SysEx: %v", customWrite)
}
}
func TestPeripheralManagerBroadcastsNoDevices(t *testing.T) {
manager := &PeripheralManager{byID: make(map[string]*managedPeripheral)}
want := "No ESP32 rover peripherals detected during startup."
if messages := manager.StartupBroadcasts(); len(messages) != 1 || messages[0] != want {
t.Fatalf("startup broadcasts = %#v, want %q", messages, want)
}
}
func TestPeripheralManagerReportsUnexpectedDisconnectOnce(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
// Closing the fake read stream models an unplugged USB serial adapter. The
// manager should publish one identified failure and never attempt reconnect.
_ = connection.Close()
select {
case failure := <-manager.Failures():
if failure.ID != "firmata-0" || failure.Name != "Bench accessory" || !errors.Is(failure.Err, io.ErrClosedPipe) {
t.Fatalf("unexpected failure: %#v", failure)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for peripheral disconnect")
}
select {
case duplicate := <-manager.Failures():
t.Fatalf("unexpected duplicate disconnect: %#v", duplicate)
case <-time.After(20 * time.Millisecond):
}
}
func TestPeripheralManagerRejectsInvalidValuesBeforeWriting(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
baseline := len(connection.Bytes())
invalid := []struct {
control string
value string
}{
{control: "servoPosition", value: `181`},
{control: "lightBrightness", value: `12.5`},
{control: "specialAction", value: `"yes"`},
}
for _, testCase := range invalid {
if err := manager.SetControl("firmata-0", testCase.control, json.RawMessage(testCase.value)); err == nil {
t.Fatalf("expected %s=%s to fail", testCase.control, testCase.value)
}
}
if got := len(connection.Bytes()); got != baseline {
t.Fatalf("invalid values wrote %d bytes", got-baseline)
}
}
func TestPeripheralManagerSkipsOtherFirmataFirmware(t *testing.T) {
other := newScriptedConnection(testFirmwareFrame("StandardFirmata"))
other.timeoutsBeforeRead = 1
rover := scriptedPeripheralConnection(t, testPeripheralDescription("Rover accessory", false))
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyACM0", "/dev/ttyUSB0"},
map[string]*scriptedConnection{
"/dev/ttyACM0": other,
"/dev/ttyUSB0": rover,
},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
if inventory := manager.Inventory(); len(inventory) != 1 || inventory[0].ID != "firmata-0" || inventory[0].Name != "Rover accessory" {
t.Fatalf("unexpected inventory: %#v", inventory)
}
if !other.Closed() {
t.Fatal("non-rover Firmata port was not closed")
}
}
func TestPeripheralManagerFailsMalformedRoverDescription(t *testing.T) {
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
testCapabilityFrame(),
testDescriptionFrame([]byte(`not-json`)),
)
connection.timeoutsBeforeRead = 1
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0"},
map[string]*scriptedConnection{"/dev/ttyUSB0": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err == nil || !strings.Contains(err.Error(), "describe rover peripheral") {
t.Fatalf("expected malformed description error, got manager=%v err=%v", manager, err)
}
if !connection.Closed() {
t.Fatal("malformed rover peripheral connection was not closed")
}
}
func TestPeripheralManagerRejectsAdvertisedUnsupportedPinMode(t *testing.T) {
description := testPeripheralDescription("Bad capability", false)
rawDescription, err := json.Marshal(description)
if err != nil {
t.Fatalf("marshal description: %v", err)
}
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
[]byte{
firmataStartSysex, firmataCapabilityReply,
FirmataPinModeOutput, 1, 0x7F,
firmataEndSysex,
},
testDescriptionFrame(rawDescription),
)
connection.timeoutsBeforeRead = 1
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0"},
map[string]*scriptedConnection{"/dev/ttyUSB0": connection},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err == nil || !strings.Contains(err.Error(), "Firmata reported only 1 pins") {
t.Fatalf("expected unsupported capability error, got manager=%v err=%v", manager, err)
}
if !connection.Closed() {
t.Fatal("unsupported peripheral connection was not closed")
}
}
func TestPeripheralManagerRejectsDuplicateBuiltInProvidersWhenRoleIsSelected(t *testing.T) {
first := scriptedPeripheralConnection(t, testPeripheralDescription("First", true))
second := scriptedPeripheralConnection(t, testPeripheralDescription("Second", true))
dependencies := testPeripheralDiscoveryDependencies(
[]string{"/dev/ttyUSB0", "/dev/ttyUSB1"},
map[string]*scriptedConnection{
"/dev/ttyUSB0": first,
"/dev/ttyUSB1": second,
},
)
manager, err := discoverPeripheralManager(context.Background(), "/dev/roomba", discardLogger(), dependencies)
if err != nil {
t.Fatalf("discovery should retain providers until native precedence is known: %v", err)
}
defer manager.Close()
if _, err := manager.NewFirmataToggle("headlight", discardLogger()); err == nil || !strings.Contains(err.Error(), "role headlight has multiple providers") {
t.Fatalf("expected duplicate provider selection error, got %v", err)
}
if first.Closed() || second.Closed() {
t.Fatal("selection validation unexpectedly closed manager-owned ports")
}
}
func TestPeripheralManagerReturnsHardwareWriteFailure(t *testing.T) {
connection := scriptedPeripheralConnection(t, testPeripheralDescription("Bench accessory", false))
manager, err := discoverPeripheralManager(
context.Background(),
"/dev/roomba",
discardLogger(),
testPeripheralDiscoveryDependencies([]string{"/dev/accessory"}, map[string]*scriptedConnection{"/dev/accessory": connection}),
)
if err != nil {
t.Fatalf("discover: %v", err)
}
defer manager.Close()
connection.SetWriteError(errors.New("USB device removed"))
err = manager.SetControl("firmata-0", "lightBrightness", json.RawMessage(`128`))
if err == nil || !strings.Contains(err.Error(), "USB device removed") {
t.Fatalf("expected hardware error, got %v", err)
}
select {
case failure := <-manager.Failures():
if failure.ID != "firmata-0" || !strings.Contains(failure.Err.Error(), "USB device removed") {
t.Fatalf("unexpected write failure notification: %#v", failure)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for write failure notification")
}
}
func TestPeripheralManagerPassesRoombaDeviceToCandidateExclusion(t *testing.T) {
const roombaDevice = "/dev/serial/by-id/roomba-base"
listed := false
dependencies := peripheralDiscoveryDependencies{
listCandidates: func(excluded string) ([]string, error) {
listed = true
if excluded != roombaDevice {
t.Fatalf("excluded device = %q, want %q", excluded, roombaDevice)
}
return nil, nil
},
open: func(string) (io.ReadWriteCloser, error) { return nil, errors.New("unexpected open") },
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: time.Second,
}
manager, err := discoverPeripheralManager(context.Background(), roombaDevice, discardLogger(), dependencies)
if err != nil {
t.Fatalf("discover: %v", err)
}
manager.Close()
if !listed {
t.Fatal("candidate listing was not called")
}
}
func TestUniquePeripheralCandidatesPrefersStableAliasAndExcludesRoomba(t *testing.T) {
temporaryDirectory := t.TempDir()
peripheralTarget := filepath.Join(temporaryDirectory, "ttyUSB0")
roombaTarget := filepath.Join(temporaryDirectory, "ttyUSB1")
if err := os.WriteFile(peripheralTarget, nil, 0o600); err != nil {
t.Fatalf("create peripheral target: %v", err)
}
if err := os.WriteFile(roombaTarget, nil, 0o600); err != nil {
t.Fatalf("create Roomba target: %v", err)
}
stableAlias := filepath.Join(temporaryDirectory, "usb-rover-peripheral")
if err := os.Symlink(peripheralTarget, stableAlias); err != nil {
t.Fatalf("create stable alias: %v", err)
}
candidates := uniquePeripheralCandidates(
[]string{stableAlias, peripheralTarget, roombaTarget},
roombaTarget,
)
if len(candidates) != 1 || candidates[0] != stableAlias {
t.Fatalf("candidates = %v, want stable peripheral alias only", candidates)
}
}
func testPeripheralDiscoveryDependencies(paths []string, connections map[string]*scriptedConnection) peripheralDiscoveryDependencies {
return peripheralDiscoveryDependencies{
listCandidates: func(string) ([]string, error) {
return append([]string(nil), paths...), nil
},
open: func(devicePath string) (io.ReadWriteCloser, error) {
connection := connections[devicePath]
if connection == nil {
return nil, errors.New("test connection not found")
}
return connection, nil
},
sleep: func(time.Duration) {},
startupWait: 0,
handshakeWait: time.Second,
}
}
func scriptedPeripheralConnection(t *testing.T, description PeripheralDescription) *scriptedConnection {
t.Helper()
rawDescription, err := json.Marshal(description)
if err != nil {
t.Fatalf("marshal description: %v", err)
}
connection := newScriptedConnection(
testFirmwareFrame(peripheralFirmwareName),
testCapabilityFrame(),
testDescriptionFrame(rawDescription),
)
// The first read represents the quiet timeout used to drain boot output
// before the client's parser starts consuming explicit query responses.
connection.timeoutsBeforeRead = 1
return connection
}
func testPeripheralDescription(name string, provideHeadlight bool) PeripheralDescription {
minimumServo, maximumServo := 0, 180
minimumPWM, maximumPWM := 0, 255
servoPin, pwmPin := 13, 17
description := PeripheralDescription{
Name: name,
Controls: []PeripheralControl{
{
ID: "servoPosition", Type: "slider", Name: "Servo position",
Minimum: &minimumServo, Maximum: &maximumServo,
Output: PeripheralOutput{Type: "servo", Pin: &servoPin},
},
{
ID: "lightBrightness", Type: "slider", Name: "Light brightness",
Minimum: &minimumPWM, Maximum: &maximumPWM,
Output: PeripheralOutput{Type: "pwm", Pin: &pwmPin},
},
{
ID: "specialAction", Type: "button", Name: "Run special action", Mode: "momentary",
Output: PeripheralOutput{Type: "custom"},
},
},
}
if provideHeadlight {
description.RoverControls.Headlight = &PeripheralDigitalRole{Pin: 18}
}
return description
}
func testFirmwareFrame(name string) []byte {
frame := []byte{firmataStartSysex, firmataReportFirmware, 1, 0}
frame = append(frame, EncodeFirmata7Bit([]byte(name))...)
return append(frame, firmataEndSysex)
}
func testCapabilityFrame() []byte {
frame := []byte{firmataStartSysex, firmataCapabilityReply}
for pin := 0; pin < 40; pin++ {
// The test ESP32 reports the same three output modes as the reference
// firmware. Repeating real pin entries also exercises capability parsing
// independently of any particular example control pin.
frame = append(frame, FirmataPinModeOutput, 1, FirmataPinModePWM, 8, FirmataPinModeServo, 14, 0x7F)
}
return append(frame, firmataEndSysex)
}
func testDescriptionFrame(rawDescription []byte) []byte {
frame := []byte{firmataStartSysex, firmataPeripheralFeature, firmataPeripheralDescription}
frame = append(frame, EncodeFirmata7Bit(rawDescription)...)
return append(frame, firmataEndSysex)
}
func discardLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}
+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:
+112 -12
View File
@@ -19,13 +19,18 @@ type WSClient struct {
sensorFrames <-chan []byte
events chan RoverEvent
media *MediaSupervisor
servo *CameraServo
servo CameraServoController
horn *HornSynth
headlight *GPIOToggle
laser *GPIOToggle
headlight ToggleController
laser ToggleController
peripherals *PeripheralManager
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 +46,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 CameraServoController, headlight ToggleController, laser ToggleController, peripherals *PeripheralManager, logger *log.Logger, console *ConsoleNotifier) *WSClient {
var ttsQueue chan *ttsPayload
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
@@ -64,7 +69,9 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
horn: horn,
headlight: headlight,
laser: laser,
peripherals: peripherals,
log: logger,
console: console,
ttsQueue: ttsQueue,
chromeTTS: chromeTTS,
audioLevels: AudioLevels{
@@ -124,6 +131,20 @@ func (c *WSClient) Run(ctx context.Context) error {
}
func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
// Built-in metadata comes from the selected controller, not necessarily
// YAML. An ESP32 can enable a role whose native GPIO entry is disabled.
cameraServoConfig := CameraServoConfig{}
if c.servo != nil {
cameraServoConfig = c.servo.Configuration()
}
headlightConfig := GPIOToggleConfig{}
if c.headlight != nil {
headlightConfig = c.headlight.Configuration()
}
laserConfig := GPIOToggleConfig{}
if c.laser != nil {
laserConfig = c.laser.Configuration()
}
msg := helloMessage{
Type: "hello",
Name: c.cfg.Name,
@@ -132,11 +153,12 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
CameraServo: cameraServoConfig,
Audio: c.cfg.Audio,
Horn: c.cfg.Horn,
Headlight: c.cfg.Headlight,
Laser: c.cfg.Laser,
Headlight: headlightConfig,
Laser: laserConfig,
Peripherals: c.peripherals.Inventory(),
Private: c.cfg.Private,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
@@ -233,6 +255,8 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.handleToggleCommand("headlight", c.headlight, msg.Headlight)
case msg.Laser != nil:
return c.handleToggleCommand("laser", c.laser, msg.Laser)
case msg.Peripheral != nil:
return c.peripherals.SetControl(msg.Peripheral.ID, msg.Peripheral.Control, msg.Peripheral.Value)
case msg.Song != nil:
slot := 0
if msg.Song.Slot != nil {
@@ -248,7 +272,7 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
}
}
func (c *WSClient) handleToggleCommand(name string, toggle *GPIOToggle, payload *togglePayload) error {
func (c *WSClient) handleToggleCommand(name string, toggle ToggleController, payload *togglePayload) error {
if toggle == nil {
return fmt.Errorf("%s disabled", name)
}
@@ -305,6 +329,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 +356,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 +521,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 +561,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 +671,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 +685,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 +705,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 +723,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 +740,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 +766,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 +785,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
+5 -2
View File
@@ -7,8 +7,9 @@
- not allowed
- snapshots
- non-turn video
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots)
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots after the user threshold is exceeded)
- live (rover non-active turn holders and PTZ non-operators can get full video)
- userThreshold (snapshots turn on when controllable users exceed this number)
- external spectator video
- snapshots (external spectators are only allowed snapshots)
- live (external spectators can get full video)
@@ -23,7 +24,9 @@
```yaml
bandwidthSavings:
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
nonTurnVideo: "snapshots" # snapshots | live
nonTurnVideo:
mode: "snapshots" # snapshots | live
userThreshold: 0 # snapshots turn on when controllable users exceed this number
externalSpectatorVideo: "snapshots" # snapshots | live
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
```
-218
View File
@@ -1,218 +0,0 @@
admins:
- username: admin
password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass
discord_id: "1234567890"
lockdown: false
- username: lockdown
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
discord_id: "0987654321"
lockdown: true
timezone: "America/New_York"
interInstance:
enabled: false
directoryUrls:
- "https://raw.githubusercontent.com/legop3/multi-roomba-rover-instance-directory/refs/heads/main/directory.json"
pollIntervalMs: 30000
requestTimeoutMs: 5000
profile:
publicUrl: "https://rover.example.com"
name: "Example Rover Server"
description: "A short public description of this rover server."
color: "#38bdf8"
llmCommentary:
enabled: false
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
frequency: 120000
overseerControl:
enabled: false
# autonomous runs the existing vote-gated loop forever; directAddress only
# runs one cycle when a chat message mentions the configured name.
mode: "autonomous"
observeOnly: true
postToolsOnlyMessages: false
tiebreakerEnable: false
runWhileNoPeopleOnline: false
name: "The Overseer"
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000
barcodeGames:
enabled: false
botName: "Barcode Games"
profileImageUrl: "https://example.com/barcode-games.png"
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"
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"
# Live video for users who are attached to a source but do not currently own
# its active turn. "snapshots" saves upload bandwidth; "live" allows full
# video whenever the normal mode/visibility rules allow it.
nonTurnVideo: "snapshots"
# 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
ffmpegBin: "ffmpeg"
streamSuffix: "-fwd"
maxUploadBytes: 8388608
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
homeAssistant:
enabled: false
url: "http://homeassistant.local:8123"
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
neato:
enabled: false
# ESPHome device name, used to derive gen3 entities:
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
device: "neato_vacuum"
lift:
enabled: false
# Two Home Assistant switches controlling lift direction.
# Raise sequence: down off -> wait interlockMs -> up on
# Lower sequence: up off -> wait interlockMs -> down on
upSwitch: "switch.lift_up"
downSwitch: "switch.lift_down"
interlockMs: 2000
commandCooldownMs: 3000
entities:
- id: "light.lab_main"
name: "Lab Lights"
- id: "switch.dock_power"
name: "Dock Power"
# type is optional; if omitted it is inferred from the entity id (light/switch)
# For room-light policy, all configured entities are treated as room lights (including switches).
buttons:
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
- entityId: "sensor.basement_rover_buttons_action"
# Human alert button
stateEquals: "on"
cooldownMs: 15000
action: "humanAlert"
- entityId: "sensor.basement_rover_buttons_action"
# Mode button: turns
stateEquals: "double"
cooldownMs: 2000
action: "modeTurns"
- entityId: "sensor.basement_rover_buttons_action"
# Mode button: admin
stateEquals: "hold"
cooldownMs: 2000
action: "modeAdmin"
- entityId: "sensor.basement_rover_buttons_action"
# Room lights lock toggle
stateEquals: "toggle"
cooldownMs: 1000
action: "lightsLockToggle"
roomCameras:
enabled: false
cameras:
- id: "lobby"
name: "Lobby Camera"
description: "Wide shot of the staging area."
url: "http://192.168.0.50/snapshot.jpg"
streamUrl: "http://192.168.0.50/stream.mjpg"
- id: "workshop"
name: "Workshop Bench"
description: "Shows the workbench and charging docks."
url: "http://192.168.0.51/snapshot.jpg"
streamUrl: "http://192.168.0.51/stream.mjpg"
ptzCamera:
enabled: false
name: "PTZ Camera"
host: "192.168.0.8"
onvifPort: 8000
username: "admin"
password: "REPLACE_WITH_CAMERA_PASSWORD"
# The Reolink TrackMix autotrack profile was token 003 during commissioning.
# Keeping this configurable lets firmware/profile resets be fixed without code
# changes while the integration still remains a single-camera feature.
profileToken: "003"
turnDurationMs: 300000
# PTZ replay capture needs a known-good replay encoder on the server. Keep it
# off by default so adding live PTZ does not start a broken replay worker loop.
replayEnabled: false
kinect:
enabled: false
# Capture requests are global across 3d/color so one person cannot spam room
# uploads for everyone else. This does not affect the native worker's local
# camera cache; it only gates browser-requested broadcasts.
captureCooldownMs: 10000
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"
channels:
general: "123456789012345678"
announcements: "123456789012345678"
adminAlerts: "123456789012345678"
# chat bridge is configured per guild via the shared `commands.prefix`
replay: "123456789012345678"
humanAlerts: "123456789012345678"
roles:
stalkerPing: "123456789012345678"
announcementPing: "123456789012345678"
adminPing: "123456789012345678"
humanAlertPing: "123456789012345678"
socials:
enabled: false
links:
- id: "discord"
label: "Discord"
url: "https://discord.gg/your-invite"
icon: "FaDiscord"
color: "#5865F2"
- id: "kofi"
label: "Ko-fi"
url: "https://ko-fi.com/your-handle"
icon: "FaCoffee"
color: "#29ABE0"
+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>
+21
View File
@@ -1,3 +1,10 @@
const backupRestoreService = require('./src/services/backupRestoreService');
// A staged restore must replace data before configuration, identity, or report
// services open SQLite. Requiring the service here is safe because its runtime
// HTTP/socket dependencies remain lazy until register() is called below.
backupRestoreService.applyPendingRestore();
require('./src/globals/logger');
require('./src/globals/config');
require('./src/globals/http');
@@ -8,10 +15,17 @@ require('./src/helpers/sensorDecoder');
require('./src/services/alertService');
require('./src/services/authService');
// Setup remains available only until the first lockdown administrator exists;
// the administrative configuration gateway then owns all subsequent changes.
require('./src/services/setupService');
require('./src/services/adminConfigurationService');
require('./src/services/eventBus');
require('./src/services/modeManager');
require('./src/services/lockdownGuard');
require('./src/services/roverManager');
// Help monitoring subscribes to roverManager telemetry before assignment and
// session services begin consuming the resulting roster state.
require('./src/services/roverHelpService');
require('./src/services/commandService');
require('./src/services/roverConnectionService');
require('./src/services/assignmentService');
@@ -28,6 +42,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,11 +61,17 @@ 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');
backupRestoreService.register();
require('./src/services/httpServer');
+87 -57
View File
@@ -8,14 +8,12 @@ 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 +28,10 @@ fi
TARGET_USER="$SUDO_USER"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
SERVER_DIR="$SCRIPT_DIR"
CONFIG_PATH="$SERVER_DIR/config.yaml"
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
DATA_DIR="$SERVER_DIR/data"
SNAPSHOT_DIR="$DATA_DIR/rover-snapshots"
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
@@ -134,7 +134,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 +170,37 @@ 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 "$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."
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
# 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,65 +249,66 @@ 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
if [[ -f "$MEDIAMTX_CONFIG" ]]; then
echo " Preserving existing mediaMTX config -> $MEDIAMTX_CONFIG"
else
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
fi
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
# Validate database-backed MediaMTX inputs before disabling a working legacy
# service. This performs the same build and serialization as startup without
# opening listeners or leaving a process behind.
runuser -u "$TARGET_USER" -- env \
SERVER_DATA_DIR="$DATA_DIR" \
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
# The repository data directory is the legacy deployment's single persistence
# root and becomes the one bind-mounted /data directory during containerization.
# Create only the snapshot child eagerly because MediaMTX's hook writes there;
# the other services already create their own children when those features run.
mkdir -p "$DATA_DIR" "$SNAPSHOT_DIR"
chown "$TARGET_USER":"$TARGET_USER" "$DATA_DIR" "$SNAPSHOT_DIR"
# Previous installers used these two /var/lib directories. Replay code already
# stopped reading its old location, and snapshots regenerate immediately, so do
# not merge possibly stale runtime media over the new canonical data tree. Keep
# an existing directory untouched and report it for deliberate cleanup after the
# operator verifies the upgraded server.
for legacy_dir in /var/lib/rover-snapshots /var/lib/replay-segments; do
if [[ -d "$legacy_dir" ]]; then
echo " Legacy runtime directory is no longer used: $legacy_dir"
fi
done
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
Group=$TARGET_USER
WorkingDirectory=$SERVER_DIR
Environment=NODE_ENV=production
Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=SERVER_DATA_DIR=$DATA_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
# Application-requested restarts use the same clean SIGTERM path as an
# operator stop. Restart=always lets that process exit come back automatically,
# while an explicit `systemctl stop` still remains stopped by systemd design.
Restart=always
RestartSec=2
SuccessExitStatus=130 143
@@ -286,21 +316,21 @@ 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 "Open /setup to initialize the installation, then use /admin for administration."
echo "For fresh setup, read the one-time code from $DATA_DIR/setup-code.txt."
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 Balance Board support in /admin, 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
+12 -2
View File
@@ -5,7 +5,16 @@
set -euo pipefail
PATH_NAME="${MTX_PATH:-}"
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
# Node resolves and supplies SERVER_DATA_DIR when it starts MediaMTX, and
# MediaMTX carries that environment into this runOnReady hook. Requiring that
# single root prevents the writer from silently recreating the former /var/lib
# snapshot store while the readers are looking inside the mounted data folder.
if [[ -z "${SERVER_DATA_DIR:-}" ]]; then
echo "SERVER_DATA_DIR is required for rover snapshot output" >&2
exit 1
fi
SNAP_DIR="${SERVER_DATA_DIR}/rover-snapshots"
# Ignore non-rover-video paths.
case "$PATH_NAME" in
@@ -31,7 +40,8 @@ case "$PATH_NAME" in
esac
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
-rtsp_transport tcp \
-i "rtsp://127.0.0.1:8554/${PATH_NAME}" \
-an \
-vf "$FILTER" \
-q:v "$QUALITY" \
+4400
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -5,17 +5,23 @@
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"check:media": "node scripts/checkMedia.js"
"check:media": "node scripts/checkMedia.js",
"admin:recover": "node scripts/adminAccount.js"
},
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.11.1",
"discord.js": "^14.25.1",
"dockerode": "^5.0.1",
"express": "^4.19.2",
"fuse.js": "^7.4.2",
"home-assistant-js-websocket": "^3.1.2",
"http-proxy-middleware": "^3.0.7",
"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",
@@ -23,6 +29,7 @@
"reolink-nvr-api": "^0.3.0",
"sharp": "^0.33.5",
"socket.io": "^4.7.5",
"tar": "^7.5.22",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.configuration-tree{margin-top:.25rem}.configuration-tree>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.configuration-card{min-width:0px}.configuration-card-header{top:var(--configuration-sticky-top, 0px)}.configuration-card .configuration-card{margin-left:1rem;width:calc(100% - 1rem)}.configuration-card-body>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-card-body{padding:.125rem}.configuration-children>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.configuration-line{display:grid;min-width:0px;grid-template-columns:repeat(1,minmax(0,1fr));align-items:flex-start;gap:.125rem;border-radius:.375rem;--tw-bg-opacity: 1;background-color:rgb(38 38 38 / var(--tw-bg-opacity));padding:.125rem}@media(min-width:640px){.configuration-line{grid-template-columns:minmax(9rem,16rem) minmax(12rem,40rem)}}.configuration-line{justify-content:start}.configuration-key,.configuration-value{min-width:0px}.configuration-key-label{font-size:1rem;line-height:1.5rem;font-weight:600;line-height:1.375;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-root-description,.configuration-branch-description,.configuration-item-description,.configuration-value-description{display:block;font-size:.875rem;line-height:1.25rem;line-height:1.375;--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity));margin-top:.125rem}.configuration-root-description{margin-bottom:.25rem}.configuration-branch-description,.configuration-item-description{max-width:56rem}.configuration-value-description{margin-bottom:.125rem;max-width:40rem}.configuration-value input:not([type=checkbox]),.configuration-value select,.configuration-value textarea{width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(82 82 82 / var(--tw-border-opacity));--tw-bg-opacity: 1;background-color:rgb(64 64 64 / var(--tw-bg-opacity));padding:.125rem;--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::-moz-placeholder,.configuration-value select::-moz-placeholder,.configuration-value textarea::-moz-placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox])::placeholder,.configuration-value select::placeholder,.configuration-value textarea::placeholder{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity))}.configuration-value input:not([type=checkbox]):focus,.configuration-value select:focus,.configuration-value textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-opacity: 1;--tw-ring-color: rgb(14 165 233 / var(--tw-ring-opacity))}.configuration-value input[type=checkbox]{height:1rem;width:1rem;vertical-align:middle;accent-color:#0ea5e9}.configuration-value .checkbox label{display:flex;min-height:1.75rem;align-items:center;--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity))}.configuration-value .error-detail{margin-top:.125rem;font-size:.75rem;line-height:1rem;--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity))}.configuration-value .help-block{margin-top:.125rem;display:block;font-size:.7rem;--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity))}.configuration-secret{min-width:0px}.configuration-item-actions,.configuration-array-actions{display:flex;flex-wrap:wrap;gap:.125rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DyvmFiy4.js";import{l as q,m as D,n as I}from"./api-BVIrbPAA.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
//# sourceMappingURL=SetupApp-BiB6Mx6k.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
function o(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const n=new Error(r.error);n.code=r.code||null,n.validationErrors=r.validationErrors||[],n.currentRevision=r.currentRevision||null,s(n);return}e(r)})})}const c=t=>o(t,"adminConfig:get"),u=(t,i)=>o(t,"adminConfig:confirmPassword",{password:i}),d=(t,i)=>o(t,"adminConfig:updateConfiguration",i),m=(t,i)=>o(t,"adminConfig:importConfigurationFile",i),f=(t,i)=>o(t,"adminConfig:restoreRevision",i),p=(t,i)=>o(t,"adminConfig:createAdministrator",i),l=(t,i)=>o(t,"adminConfig:updateAdministrator",i),g=(t,i)=>o(t,"adminConfig:deleteAdministrator",{id:i}),C=t=>o(t,"server:restartApplication"),A=t=>o(t,"backupRestore:status"),R=t=>o(t,"backupRestore:createBackup"),k=t=>o(t,"backupRestore:createRestoreUpload"),v=(t,i)=>o(t,"backupRestore:confirmRestore",{restoreId:i}),F=t=>o(t,"setup:status"),b=(t,i)=>o(t,"setup:createAdministrator",i),w=(t,i)=>o(t,"setup:importConfigurationFile",i);export{C as a,R as b,p as c,g as d,k as e,v as f,A as g,d as h,m as i,c as j,u as k,F as l,b as m,w as n,f as r,l as u};
//# sourceMappingURL=api-BVIrbPAA.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"api-BVIrbPAA.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\nexport const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');\nexport const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');\nexport const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');\nexport const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');\nexport const confirmFullRestore = (socket, restoreId) => emitAdminRequest(socket, 'backupRestore:confirmRestore', { restoreId });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","restartApplication","getBackupRestoreStatus","createFullBackup","createRestoreUpload","confirmFullRestore","restoreId","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EACxGC,EAAsBjB,GAAWD,EAAiBC,EAAQ,2BAA2B,EACrFkB,EAA0BlB,GAAWD,EAAiBC,EAAQ,sBAAsB,EACpFmB,EAAoBnB,GAAWD,EAAiBC,EAAQ,4BAA4B,EACpFoB,EAAuBpB,GAAWD,EAAiBC,EAAQ,mCAAmC,EAC9FqB,EAAqB,CAACrB,EAAQsB,IAAcvB,EAAiBC,EAAQ,+BAAgC,CAAE,UAAAsB,CAAS,CAAE,EAElHC,EAAkBvB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEwB,EAA2B,CAACxB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GuB,EAA0B,CAACzB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
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-B8ElczOE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ZpgWUPKf.css">
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-DyvmFiy4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.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"
}
]
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Administrator Recovery Command
// Purpose: Creates or resets a lockdown administrator when web authentication cannot be repaired through /admin.
// Scope: Performs one explicit local database mutation and never creates a recurring startup bypass.
const bcrypt = require('bcrypt');
const { getConfigurationDatabase } = require('../src/configuration');
function usage() {
process.stderr.write('Usage: node scripts/adminAccount.js <username> <password> [discord-id]\n');
}
async function main() {
const [username, password, discordId = ''] = process.argv.slice(2);
if (!username || !password) {
usage();
process.exitCode = 2;
return;
}
if (password.length < 10) throw new Error('Administrator password must be at least 10 characters.');
const database = getConfigurationDatabase();
const existing = database.findAdministratorForAuthentication(username);
const passwordHash = await bcrypt.hash(password, 12);
if (existing) {
database.updateAdministrator(existing.id, { passwordHash, role: 'lockdown', discordId }, 'command-line-recovery');
process.stdout.write(`Reset lockdown administrator ${existing.username}.\n`);
return;
}
const created = database.createAdministrator({ username, passwordHash, discordId, role: 'lockdown' }, 'command-line-recovery');
process.stdout.write(`Created lockdown administrator ${created.username}.\n`);
}
main().catch((error) => {
process.stderr.write(`Administrator recovery failed: ${error.message}\n`);
process.exitCode = 1;
});
+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/configuration');
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');
@@ -0,0 +1,508 @@
// Configuration System Tests
// Purpose: Verifies strict defaults, immutable revisions, secret handling, explicit setup-file import, and administrator safety.
// Scope: Uses isolated temporary databases and never opens the development server's data store.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const Database = require('better-sqlite3');
const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validation');
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
const { migrations } = require('./migrations');
const { getFeatureFlags } = require('./index');
const { createConfigurationDatabase } = require('./database');
const {
parseConfigurationFile,
buildSecretOperationsForImport,
importConfigurationFile,
} = require('./configurationFileImporter');
const temporaryRoots = [];
function createTestDatabase() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-configuration-'));
temporaryRoots.push(root);
return createConfigurationDatabase({ databasePath: path.join(root, 'configuration.sqlite') });
}
function collectUndocumentedSchemaPaths(schema, pathLabel = '$') {
/*
The admin editor is entirely schema-generated, so missing schema prose is
missing operator documentation. Walk objects, arrays, array item schemas,
and scalar leaves instead of checking only named service definitions; this
makes every visible level of the hierarchy uphold the same contract.
*/
if (!schema || typeof schema !== 'object') return [];
const missing = typeof schema.description === 'string' && schema.description.trim()
? []
: [pathLabel];
if (schema.properties) {
Object.entries(schema.properties).forEach(([key, childSchema]) => {
missing.push(...collectUndocumentedSchemaPaths(childSchema, `${pathLabel}.${key}`));
});
}
if (schema.items) {
missing.push(...collectUndocumentedSchemaPaths(schema.items, `${pathLabel}[]`));
}
return missing;
}
function collectSchemaPathsMissingInputExamples(schema, value, pathLabel = '$', insideArray = false) {
/*
Universal defaults such as timeouts and modes are real saved values. Empty
strings and newly-created array items are different: they require an
installation-specific value, so the admin form must show an example without
persisting a fake hostname, credential, or hardware ID. This walk enforces
that distinction across both the current default document and array shapes.
*/
if (!schema || typeof schema !== 'object') return [];
if (schema.type === 'array') {
return collectSchemaPathsMissingInputExamples(schema.items, undefined, `${pathLabel}[]`, true);
}
if (schema.type === 'object') {
return Object.entries(schema.properties || {}).flatMap(([key, childSchema]) => (
collectSchemaPathsMissingInputExamples(childSchema, value?.[key], `${pathLabel}.${key}`, insideArray)
));
}
// Enumerations and checkboxes already communicate their accepted shape
// through their controls, so placeholder examples are only required for
// otherwise free-form empty scalar inputs.
const needsExample = (value === '' || insideArray)
&& !Array.isArray(schema.enum)
&& schema.type !== 'boolean';
if (!needsExample) return [];
return Array.isArray(schema.examples) && schema.examples.length ? [] : [pathLabel];
}
function collectEmptyStringPaths(value, pathLabel = '$') {
/*
Empty-string policy is intentionally tested by path because these four
fields are exceptional for security or visible behavior, not omissions in
the legacy-style default document. Walking the complete value also catches
an accidentally blank field inside a pre-populated example collection.
*/
if (Array.isArray(value)) {
return value.flatMap((item, index) => collectEmptyStringPaths(item, `${pathLabel}[${index}]`));
}
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([key, childValue]) => (
collectEmptyStringPaths(childValue, `${pathLabel}.${key}`)
));
}
return value === '' ? [pathLabel] : [];
}
test.after(() => {
temporaryRoots.forEach((root) => fs.rmSync(root, { recursive: true, force: true }));
});
test('safe defaults form a complete valid configuration with integrations disabled', () => {
assert.doesNotThrow(() => assertValidConfig(defaultConfig));
assert.equal(defaultConfig.discord.enabled, false);
assert.equal(defaultConfig.homeAssistant.enabled, false);
assert.equal(defaultConfig.ptzCamera.enabled, false);
assert.equal(defaultConfig.balanceBoard.enabled, false);
});
test('legacy-style defaults populate every non-secret and inactive-content value', () => {
/*
Credentials must not masquerade as configured, and driver HTML would be
immediately visible without an enable switch. Every other free-form value
should match the populated template behavior operators had with YAML.
*/
assert.deepEqual(collectEmptyStringPaths(defaultConfig), [
'$.homeAssistant.token',
'$.ptzCamera.password',
'$.discord.token',
'$.driverAd.html',
]);
assert.ok(defaultConfig.interInstance.directoryUrls.length > 0);
assert.ok(defaultConfig.homeAssistant.entities.length > 0);
assert.ok(defaultConfig.homeAssistant.buttons.length > 0);
assert.ok(defaultConfig.roomCameras.cameras.length > 0);
assert.ok(defaultConfig.socials.links.length > 0);
});
test('service definitions determine document order and write-only secret handling', () => {
/*
The generic browser form and backend persistence both consume this one
assembled schema. Guarding composition order and derived secret paths here
prevents either consumer from needing its own parallel registry.
*/
assert.deepEqual(Object.keys(defaultConfig), definitions.map(({ key }) => key));
assert.deepEqual(Object.keys(rootSchema.properties), Object.keys(defaultConfig));
assert.deepEqual(secretPaths, ['homeAssistant.token', 'ptzCamera.password', 'discord.token']);
assert.equal(rootSchema.properties.homeAssistant.properties.token.writeOnly, true);
assert.equal(rootSchema.properties.ptzCamera.properties.password.writeOnly, true);
assert.equal(rootSchema.properties.discord.properties.token.writeOnly, true);
});
test('every configuration section, collection, item, and option has an operator description', () => {
/*
New configuration remains self-documenting by default. Reporting every
dotted path in one assertion gives a contributor an exact repair list and
avoids recreating a separately maintained documentation registry.
*/
assert.deepEqual(collectUndocumentedSchemaPaths(rootSchema), []);
});
test('empty installation-specific fields and array item inputs provide schema-owned examples', () => {
/*
The frontend derives placeholders from these examples generically. Keeping
this assertion beside schema composition prevents an empty, unexplained box
from returning when a service adds configuration in the future.
*/
assert.deepEqual(collectSchemaPathsMissingInputExamples(rootSchema, defaultConfig), []);
});
test('service definitions generate public feature paths without a separate registry', () => {
/*
This order follows the one configuration document, including nested Neato
and lift definitions beneath Home Assistant. The assertion makes duplicate,
omitted, or centrally reintroduced feature names visible during review.
*/
assert.deepEqual(featureDefinitions, [
{ key: 'interInstance', path: ['interInstance', 'enabled'] },
{ key: 'barcodeGames', path: ['barcodeGames', 'enabled'] },
{ key: 'homeAssistant', path: ['homeAssistant', 'enabled'] },
{ key: 'neato', path: ['homeAssistant', 'neato', 'enabled'] },
{ key: 'lift', path: ['homeAssistant', 'lift', 'enabled'] },
{ key: 'roomCameras', path: ['roomCameras', 'enabled'] },
{ key: 'ptzCamera', path: ['ptzCamera', 'enabled'] },
{ key: 'kinect', path: ['kinect', 'enabled'] },
{ key: 'balanceBoard', path: ['balanceBoard', 'enabled'] },
{ key: 'buttonBox', path: ['buttonBox', 'enabled'] },
{ key: 'barcodeScanner', path: ['barcodeScanner', 'enabled'] },
{ key: 'discord', path: ['discord', 'enabled'] },
{ key: 'socials', path: ['socials', 'enabled'] },
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
]);
});
test('generated feature flags use only each declared enabled switch', () => {
/*
This deliberately describes services without usable credentials, devices,
or enabled parents. Readiness belongs to runtime health, so the generated
public flags must still preserve each operator-selected switch exactly.
*/
const flags = getFeatureFlags({
homeAssistant: {
enabled: false,
lift: { enabled: true },
neato: { enabled: true },
},
roomCameras: { enabled: true, cameras: [] },
barcodeScanner: { enabled: false },
barcodeGames: { enabled: true },
socials: { enabled: true, links: [] },
ptzCamera: { enabled: true, host: '', username: '', password: '' },
discord: { enabled: true, token: '' },
});
assert.equal(flags.homeAssistant, false);
assert.equal(flags.lift, true);
assert.equal(flags.neato, true);
assert.equal(flags.roomCameras, true);
assert.equal(flags.barcodeScanner, false);
assert.equal(flags.barcodeGames, true);
assert.equal(flags.socials, true);
assert.equal(flags.ptzCamera, true);
assert.equal(flags.discord, true);
});
test('normalization fills missing legacy fields but strict validation rejects unknown fields', () => {
const normalized = normalizeConfig({ media: { additionalHosts: [] } });
assert.equal(normalized.publicUrl, 'https://rover.example.com');
assert.deepEqual(normalized.media.additionalHosts, []);
assert.doesNotThrow(() => assertValidConfig(normalized));
const invalid = normalizeConfig({ media: { additionalHosts: [], misspelledHost: 'x' } });
assert.throws(() => assertValidConfig(invalid), (error) => {
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
assert.ok(error.validationErrors.some((entry) => entry.path.includes('misspelledHost')));
return true;
});
});
test('database migration consolidates existing public URLs and removes obsolete media addressing', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-public-url-migration-'));
temporaryRoots.push(root);
const databasePath = path.join(root, 'configuration.sqlite');
const legacyDatabase = new Database(databasePath);
legacyDatabase.exec(`
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
${migrations[0].sql}
`);
legacyDatabase.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (1, ?)').run(Date.now());
const legacyConfig = structuredClone(defaultConfig);
delete legacyConfig.publicUrl;
legacyConfig.interInstance.profile.publicUrl = 'https://rover.example.com';
legacyConfig.discord.enabled = true;
legacyConfig.discord.siteUrl = 'https://canonical.example.com';
legacyConfig.media.whepBaseUrl = 'http://127.0.0.1:8889/video';
const inserted = legacyDatabase.prepare(`
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
VALUES (?, ?, 'test', 'legacy-shape')
`).run(JSON.stringify(legacyConfig), Date.now());
legacyDatabase.prepare('INSERT INTO configuration_state (singleton, active_revision_id) VALUES (1, ?)')
.run(inserted.lastInsertRowid);
legacyDatabase.close();
const migrated = createConfigurationDatabase({ databasePath });
const active = migrated.getActiveConfigurationRecord().config;
assert.equal(active.publicUrl, 'https://canonical.example.com');
assert.equal(Object.hasOwn(active.interInstance.profile, 'publicUrl'), false);
assert.equal(Object.hasOwn(active.discord, 'siteUrl'), false);
assert.equal(Object.hasOwn(active.media, 'whepBaseUrl'), false);
migrated.close();
});
test('full-document updates preserve secrets and reject a stale browser revision', () => {
const database = createTestDatabase();
const initial = database.getActiveConfigurationRecord();
const tokenRevision = database.updateConfiguration({
value: database.getClientConfiguration().config,
expectedRevision: initial.revision,
actor: 'test',
secretOperations: {
'discord.token': { action: 'replace', value: 'super-secret-token' },
},
});
const client = database.getClientConfiguration();
assert.equal(client.config.discord.token, '');
assert.equal(client.configuredSecrets['discord.token'], true);
const editedConfiguration = structuredClone(client.config);
editedConfiguration.discord.enabled = true;
const nextRevision = database.updateConfiguration({
value: editedConfiguration,
expectedRevision: tokenRevision,
actor: 'test',
});
assert.equal(database.getActiveConfigurationRecord().config.discord.token, 'super-secret-token');
assert.throws(() => database.updateConfiguration({
value: editedConfiguration,
expectedRevision: tokenRevision,
actor: 'stale-test',
}), (error) => error.code === 'CONFIG_REVISION_CONFLICT' && error.currentRevision === nextRevision);
const rollbackRevision = database.restoreConfigurationRevision({
revision: tokenRevision,
expectedRevision: nextRevision,
actor: 'rollback-test',
});
assert.ok(rollbackRevision > nextRevision);
assert.equal(database.getActiveConfigurationRecord().config.discord.enabled, false);
database.close();
});
test('administrator storage never exposes hashes or removes the final lockdown administrator', () => {
const database = createTestDatabase();
const lockdown = database.createAdministrator({
username: 'owner',
passwordHash: '$2b$10$example',
role: 'lockdown',
});
const listed = database.listAdministrators();
assert.equal(listed.length, 1);
assert.equal(Object.hasOwn(listed[0], 'passwordHash'), false);
assert.throws(() => database.deleteAdministrator(lockdown.id, 'test'), /final lockdown administrator/);
assert.throws(() => database.updateAdministrator(lockdown.id, { role: 'admin' }, 'test'), /final lockdown administrator/);
database.close();
});
test('an explicitly uploaded YAML imports current fields, ignores obsolete keys, and preserves bcrypt hashes exactly once', () => {
const yamlText = `
admins:
- username: owner
password_hash: "$2b$10$preservedHash"
discord_id: "1234"
lockdown: true
publicUrl: https://production.example.com
timezone: America/Chicago
media:
whepBaseUrl: http://localhost:8889/video
overseerControl:
enabled: false
heartbeatMs: 30000
alwaysRunModel: false
homeAssistant:
neato:
enabled: false
brainslugHost: neato-vacuum.local
brainslugKey: retired-secret
brainslugLogFile: /tmp/retired.log
roomCameras:
enabled: true
cameras:
- id: stream-only
name: Stream-only camera
streamUrl: http://camera.local/stream.mjpg
discord:
channels:
chatBridge: "123456789012345678"
roles:
stalker: "123456789012345678"
fleetReports:
discord:
immediateCriticalAlerts: true
`;
const parsed = parseConfigurationFile(yamlText);
assert.equal(parsed.config.publicUrl, 'https://production.example.com');
assert.equal(parsed.config.timezone, 'America/Chicago');
assert.equal(parsed.administrators[0].passwordHash, '$2b$10$preservedHash');
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'heartbeatMs'), false);
assert.equal(Object.hasOwn(parsed.config.overseerControl, 'alwaysRunModel'), false);
assert.equal(Object.hasOwn(parsed.config.homeAssistant.neato, 'brainslugHost'), false);
assert.equal(Object.hasOwn(parsed.config.discord.channels, 'chatBridge'), false);
assert.equal(Object.hasOwn(parsed.config.discord.roles, 'stalker'), false);
assert.equal(Object.hasOwn(parsed.config.fleetReports.discord, 'immediateCriticalAlerts'), false);
assert.deepEqual(parsed.config.roomCameras.cameras, [{
id: 'stream-only',
name: 'Stream-only camera',
streamUrl: 'http://camera.local/stream.mjpg',
}]);
const database = createTestDatabase();
const result = importConfigurationFile({ text: yamlText, database });
assert.equal(result.administratorCount, 1);
assert.equal(database.findAdministratorForAuthentication('OWNER').passwordHash, '$2b$10$preservedHash');
assert.throws(() => importConfigurationFile({ text: yamlText, database }), /cannot replace an initialized installation/);
database.close();
});
test('uploaded YAML still rejects invalid values for fields in the current schema', () => {
const yamlText = `
admins:
- username: owner
password_hash: "$2b$10$preservedHash"
lockdown: true
bandwidthSavings:
multiTabProtection: unsupported-mode
`;
assert.throws(() => parseConfigurationFile(yamlText), (error) => {
assert.equal(error.code, 'CONFIG_VALIDATION_FAILED');
assert.ok(error.validationErrors.some((entry) => entry.path === '/bandwidthSavings/multiTabProtection'));
return true;
});
});
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
const database = createTestDatabase();
const initial = database.getClientConfiguration();
const seededRevision = database.updateConfiguration({
value: initial.config,
expectedRevision: initial.revision,
actor: 'secret-seed',
secretOperations: {
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
'discord.token': { action: 'replace', value: 'replace-this-token' },
},
});
const yamlText = `
admins:
- this obsolete account entry is deliberately malformed
timezone: America/Chicago
ptzCamera:
password:
discord:
token: new-discord-token
`;
/*
An initialized installation treats the YAML as configuration data only.
Even malformed account data is ignored, while presence-aware secret
operations preserve an omitted credential, clear an explicit empty value,
and replace an explicit non-empty value.
*/
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
assert.deepEqual(parsed.administrators, []);
assert.equal(parsed.uploadedAdministratorCount, 1);
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
const revision = database.updateConfiguration({
value: parsed.config,
expectedRevision: seededRevision,
secretOperations: buildSecretOperationsForImport(parsed),
actor: 'admin-import-test',
source: 'admin-yaml:production.yaml',
});
const active = database.getActiveConfigurationRecord();
assert.equal(active.revision, revision);
assert.equal(active.source, 'admin-yaml:production.yaml');
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
assert.equal(active.config.ptzCamera.password, '');
assert.equal(active.config.discord.token, 'new-discord-token');
assert.equal(active.config.timezone, 'America/Chicago');
assert.equal(database.listAdministrators().length, 0);
database.close();
});
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
temporaryRoots.push(root);
const serverRoot = path.resolve(__dirname, '../..');
const script = `
const configuration = require('./src/configuration');
const applied = [];
configuration.registerConfigurationHandler('timezone', (next, previous) => {
applied.push({ section: 'timezone', next, previous });
});
configuration.registerConfigurationHandler('media', () => {
throw new Error('simulated media reload failure');
});
const database = configuration.getConfigurationDatabase();
const record = database.getClientConfiguration();
const next = structuredClone(record.config);
next.timezone = 'America/Chicago';
next.media.additionalHosts = ['media.example.test'];
database.updateConfiguration({
value: next,
expectedRevision: record.revision,
actor: 'live-configuration-test',
});
configuration.applyCommittedConfiguration().then((application) => {
console.log(JSON.stringify({
application,
applied,
liveTimezone: configuration.loadConfig().timezone,
liveRevision: configuration.getRuntimeConfigurationRevision(),
}));
database.close();
});
`;
const output = execFileSync(process.execPath, ['-e', script], {
cwd: serverRoot,
env: { ...process.env, SERVER_DATA_DIR: root },
encoding: 'utf8',
});
const result = JSON.parse(output.trim());
/*
A failing integration remains visible in application status but cannot
roll back the valid revision or prevent an unrelated service from seeing
it. This is the central guarantee that makes live application usable on a
server where optional hardware may be offline during an ordinary edit.
*/
assert.equal(result.liveTimezone, 'America/Chicago');
assert.equal(result.liveRevision, result.application.revision);
assert.deepEqual(result.application.changedSections, ['timezone', 'media']);
assert.deepEqual(result.applied, [{
section: 'timezone',
next: 'America/Chicago',
previous: defaultConfig.timezone,
}]);
assert.deepEqual(result.application.services, [
{ section: 'timezone', status: 'applied' },
{ section: 'media', status: 'failed', error: 'simulated media reload failure' },
]);
});
@@ -0,0 +1,165 @@
// Configuration File Importer
// Purpose: Validates a deliberately uploaded legacy YAML file for first-run setup or an explicit administrative replacement.
// Scope: Startup and installation never search for or consume configuration files; every import begins with a browser-selected file.
const yaml = require('js-yaml');
const {
rootSchema,
secretPaths,
normalizeConfig,
assertValidConfig,
} = require('./validation');
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
function getAtPath(object, dottedPath) {
return String(dottedPath || '').split('.').filter(Boolean)
.reduce((value, key) => value?.[key], object);
}
function setAtPath(object, dottedPath, value) {
const parts = String(dottedPath || '').split('.').filter(Boolean);
let cursor = object;
parts.slice(0, -1).forEach((key) => {
cursor = cursor[key];
});
cursor[parts.at(-1)] = value;
}
function hasAtPath(object, dottedPath) {
/*
Presence, rather than truthiness, distinguishes an omitted legacy secret
from an explicitly empty one. An omitted credential must preserve the
running installation's value, while an empty YAML value deliberately
clears it through the same operation used by the schema form.
*/
let cursor = object;
for (const key of String(dottedPath || '').split('.').filter(Boolean)) {
if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key)) return false;
cursor = cursor[key];
}
return true;
}
function keepCurrentSchemaFields(value, schema) {
/*
An uploaded file is only a convenient seed for the current configuration;
it is not a second schema or a historical migration framework. Legacy YAML
was permissive, so real installations naturally contain keys left behind
by removed features. At object boundaries, copy only properties that exist
in today's schema and recursively apply the same rule to nested objects and
array items. Known fields retain their original values and are validated
normally afterward, so this cannot hide a malformed current setting.
*/
if (schema?.type === 'object') {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
return Object.fromEntries(Object.entries(schema.properties || {})
.filter(([key]) => Object.hasOwn(value, key))
.map(([key, childSchema]) => [key, keepCurrentSchemaFields(value[key], childSchema)]));
}
if (schema?.type === 'array') {
if (!Array.isArray(value)) return value;
return value.map((item) => keepCurrentSchemaFields(item, schema.items));
}
return value;
}
function normalizeUploadedAdministrator(entry, index) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`Administrator ${index + 1} must be an object.`);
}
const username = String(entry.username || '').trim();
const passwordHash = String(entry.password_hash || '').trim();
if (!username || !passwordHash) {
throw new Error(`Administrator ${index + 1} requires username and password_hash.`);
}
return {
username,
passwordHash,
discordId: String(entry.discord_id || '').trim(),
role: entry.lockdown ? 'lockdown' : 'admin',
};
}
function parseConfigurationFile(text, { includeAdministrators = true } = {}) {
const parsed = yaml.load(String(text || ''));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('The configuration file must contain a YAML object.');
}
const uploadedAdministrators = Array.isArray(parsed.admins) ? parsed.admins : [];
/*
First-run setup is the sole workflow allowed to create accounts from the
legacy file. An initialized server ignores the entire admins collection,
including obsolete or malformed entries, so importing configuration can
never rename accounts, replace password hashes, or remove the last
lockdown administrator.
*/
const administrators = includeAdministrators
? uploadedAdministrators.map(normalizeUploadedAdministrator)
: [];
const configInput = Object.fromEntries(
Object.entries(parsed).filter(([key]) => key !== 'admins'),
);
secretPaths.forEach((secretPath) => {
/*
YAML commonly represents `token:` as null even though the application
models an unconfigured credential as an empty string. Translate null only
for known secret fields so a plainly empty legacy credential has the same
clear meaning as the admin form; null in any ordinary current field still
fails its schema normally.
*/
if (hasAtPath(configInput, secretPath) && getAtPath(configInput, secretPath) === null) {
setAtPath(configInput, secretPath, '');
}
});
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
const providedSecretPaths = secretPaths.filter((secretPath) => hasAtPath(configInput, secretPath));
// Filtering applies only to nonexistent keys. Values retained for current
// schema fields still have to satisfy every type, range, and format rule
// before the importer can atomically initialize the database.
assertValidConfig(config);
return {
config,
administrators,
uploadedAdministratorCount: uploadedAdministrators.length,
providedSecretPaths,
};
}
function buildSecretOperationsForImport({ config, providedSecretPaths = [] }) {
return Object.fromEntries(providedSecretPaths.map((secretPath) => {
const value = getAtPath(config, secretPath);
/*
Current secret schemas are strings. Keeping this conversion beside the
importer produces the database's narrow replace/clear contract and
avoids granting the import route a way around ordinary secret handling.
*/
return value === ''
? [secretPath, { action: 'clear' }]
: [secretPath, { action: 'replace', value }];
}));
}
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
const result = parseConfigurationFile(text);
const revision = database.importConfigurationFile({
config: result.config,
administrators: result.administrators,
actor,
source,
});
return {
revision,
administratorCount: result.administrators.length,
};
}
module.exports = {
MAX_CONFIGURATION_FILE_BYTES,
parseConfigurationFile,
buildSecretOperationsForImport,
importConfigurationFile,
};
+410
View File
@@ -0,0 +1,410 @@
// Configuration Database
// Purpose: Persists complete immutable configuration revisions, administrator accounts, and administrative audit history.
// Scope: Owns SQLite transactions and invariants; transport authorization and password hashing remain service concerns.
const fs = require('fs');
const path = require('path');
const Database = require('better-sqlite3');
const { resolveDataPath } = require('../helpers/dataPaths');
const { applySchemaMigrations } = require('./migrations');
const {
defaultConfig,
secretPaths,
clone,
normalizeConfig,
assertValidConfig,
} = require('./validation');
const DEFAULT_DATABASE_PATH = resolveDataPath('configuration.sqlite');
function normalizeUsername(value) {
const username = String(value || '').trim();
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(username)) {
throw new Error('Administrator username must be 1-64 letters, numbers, dots, underscores, or hyphens.');
}
return username;
}
function normalizeRole(value) {
if (value === 'admin' || value === 'lockdown') return value;
throw new Error('Administrator role must be admin or lockdown.');
}
function splitPath(value) {
return String(value || '').split('.').filter(Boolean);
}
function getAtPath(object, dottedPath) {
return splitPath(dottedPath).reduce((value, key) => value?.[key], object);
}
function setAtPath(object, dottedPath, value) {
const parts = splitPath(dottedPath);
let cursor = object;
parts.slice(0, -1).forEach((key) => {
if (!cursor[key] || typeof cursor[key] !== 'object') cursor[key] = {};
cursor = cursor[key];
});
cursor[parts.at(-1)] = value;
}
function redactConfiguration(config) {
const redacted = clone(config);
const configuredSecrets = {};
secretPaths.forEach((secretPath) => {
configuredSecrets[secretPath] = Boolean(getAtPath(config, secretPath));
setAtPath(redacted, secretPath, '');
});
return { config: redacted, configuredSecrets };
}
function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } = {}) {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
const db = new Database(databasePath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
applySchemaMigrations(db);
const readActiveStatement = db.prepare(`
SELECT r.id, r.config_json, r.created_at, r.actor, r.source
FROM configuration_state s
JOIN configuration_revisions r ON r.id = s.active_revision_id
WHERE s.singleton = 1
`);
const insertRevisionStatement = db.prepare(`
INSERT INTO configuration_revisions (config_json, created_at, actor, source)
VALUES (?, ?, ?, ?)
`);
const activateRevisionStatement = db.prepare(`
INSERT INTO configuration_state (singleton, active_revision_id)
VALUES (1, ?)
ON CONFLICT(singleton) DO UPDATE SET active_revision_id = excluded.active_revision_id
`);
const insertAuditStatement = db.prepare(`
INSERT INTO administrative_audit_events (created_at, actor, action, details_json)
VALUES (?, ?, ?, ?)
`);
function writeAudit(actor, action, details = {}) {
/*
Callers pass deliberately small, already-redacted metadata. Configuration
values and password hashes never belong in audit details because audit
history is routinely displayed and retained longer than request bodies.
*/
insertAuditStatement.run(Date.now(), String(actor || 'system'), String(action), JSON.stringify(details));
}
const commitRevisionTransaction = db.transaction((config, metadata) => {
const current = readActiveStatement.get();
if (metadata.expectedRevision != null && Number(metadata.expectedRevision) !== Number(current?.id)) {
const error = new Error('Configuration changed in another session. Reload before saving.');
error.code = 'CONFIG_REVISION_CONFLICT';
error.currentRevision = current?.id || null;
throw error;
}
assertValidConfig(config);
const createdAt = Date.now();
const inserted = insertRevisionStatement.run(
JSON.stringify(config),
createdAt,
String(metadata.actor || 'system'),
String(metadata.source || 'admin'),
);
activateRevisionStatement.run(inserted.lastInsertRowid);
writeAudit(metadata.actor, 'configuration.saved', {
revision: Number(inserted.lastInsertRowid),
source: String(metadata.source || 'admin'),
});
return Number(inserted.lastInsertRowid);
});
const initialActiveRow = readActiveStatement.get();
if (!initialActiveRow) {
commitRevisionTransaction(clone(defaultConfig), {
actor: 'system',
source: 'first-boot-defaults',
});
} else {
/*
New service-owned fields receive their declared defaults as a new revision on
startup. Unknown or newly invalid fields still fail validation; this is a
forward schema evolution path, not a compatibility layer that discards
data it no longer understands.
*/
const storedConfig = JSON.parse(initialActiveRow.config_json);
const normalizedConfig = normalizeConfig(storedConfig);
assertValidConfig(normalizedConfig);
if (JSON.stringify(normalizedConfig) !== JSON.stringify(storedConfig)) {
commitRevisionTransaction(normalizedConfig, {
expectedRevision: Number(initialActiveRow.id),
actor: 'system',
source: 'registered-defaults',
});
}
}
function getActiveConfigurationRecord() {
const row = readActiveStatement.get();
if (!row) throw new Error('Active configuration revision is missing.');
return {
revision: Number(row.id),
config: JSON.parse(row.config_json),
createdAt: Number(row.created_at),
actor: row.actor,
source: row.source,
};
}
function getClientConfiguration() {
const record = getActiveConfigurationRecord();
const redacted = redactConfiguration(record.config);
return { ...record, ...redacted };
}
function updateConfiguration({
value,
expectedRevision,
secretOperations = {},
actor,
source = 'admin-ui',
}) {
const active = getActiveConfigurationRecord();
const candidate = clone(value);
/*
The browser edits one complete document, but its copy contains blank
placeholders in place of every stored secret. Restore all current secret
values first, then apply only explicit replace or clear operations. This
keeps the full-document save model simple without ever sending an
existing credential back to the browser.
*/
secretPaths.forEach((secretPath) => {
setAtPath(candidate, secretPath, getAtPath(active.config, secretPath));
const operation = secretOperations[secretPath];
if (!operation) return;
if (operation.action === 'clear') setAtPath(candidate, secretPath, '');
else if (operation.action === 'replace' && typeof operation.value === 'string' && operation.value.length > 0) {
setAtPath(candidate, secretPath, operation.value);
} else {
throw new Error(`Invalid secret operation for ${secretPath}.`);
}
});
return commitRevisionTransaction(candidate, {
expectedRevision,
actor,
/*
Administrative imports use this same safe update path but identify the
selected filename in revision and audit history. The source remains
server-controlled metadata and never contains configuration values.
*/
source,
});
}
function listConfigurationRevisions({ limit = 100 } = {}) {
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 100)));
return db.prepare(`
SELECT id, created_at, actor, source
FROM configuration_revisions
ORDER BY id DESC
LIMIT ?
`).all(safeLimit).map((row) => ({
revision: Number(row.id),
createdAt: Number(row.created_at),
actor: row.actor,
source: row.source,
}));
}
function restoreConfigurationRevision({ revision, expectedRevision, actor }) {
const row = db.prepare('SELECT config_json FROM configuration_revisions WHERE id = ?').get(Number(revision));
if (!row) throw new Error('Configuration revision not found.');
const restoredConfig = JSON.parse(row.config_json);
return commitRevisionTransaction(restoredConfig, {
expectedRevision,
actor,
source: `rollback-from-${Number(revision)}`,
});
}
function listAdministrators() {
return db.prepare(`
SELECT id, username, discord_id, role, created_at, updated_at
FROM administrators
ORDER BY username COLLATE NOCASE
`).all().map((row) => ({
id: Number(row.id),
username: row.username,
discordId: row.discord_id || '',
role: row.role,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}));
}
function findAdministratorForAuthentication(username) {
const normalized = String(username || '').trim();
if (!normalized) return null;
const row = db.prepare(`
SELECT id, username, password_hash, discord_id, role
FROM administrators
WHERE username = ? COLLATE NOCASE
`).get(normalized);
if (!row) return null;
return {
id: Number(row.id),
username: row.username,
passwordHash: row.password_hash,
discordId: row.discord_id || '',
role: row.role,
};
}
function countLockdownAdministrators() {
return Number(db.prepare("SELECT COUNT(*) AS count FROM administrators WHERE role = 'lockdown'").get().count);
}
const createAdministratorTransaction = db.transaction((admin, actor, audit = true) => {
const username = normalizeUsername(admin.username);
const role = normalizeRole(admin.role);
const passwordHash = String(admin.passwordHash || '').trim();
if (!passwordHash) throw new Error('Administrator password hash is required.');
const now = Date.now();
const result = db.prepare(`
INSERT INTO administrators (username, password_hash, discord_id, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(username, passwordHash, String(admin.discordId || '').trim() || null, role, now, now);
if (audit) writeAudit(actor, 'administrator.created', { administratorId: Number(result.lastInsertRowid), username, role });
return Number(result.lastInsertRowid);
});
function createAdministrator(admin, actor = 'system') {
const id = createAdministratorTransaction(admin, actor, true);
return listAdministrators().find((entry) => entry.id === id);
}
const updateAdministratorTransaction = db.transaction((id, changes, actor) => {
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
if (!current) throw new Error('Administrator not found.');
const username = changes.username == null ? current.username : normalizeUsername(changes.username);
const role = changes.role == null ? current.role : normalizeRole(changes.role);
const discordId = changes.discordId == null ? current.discord_id : String(changes.discordId || '').trim() || null;
const passwordHash = changes.passwordHash == null ? current.password_hash : String(changes.passwordHash || '').trim();
if (!passwordHash) throw new Error('Administrator password hash is required.');
if (current.role === 'lockdown' && role !== 'lockdown' && countLockdownAdministrators() <= 1) {
throw new Error('The final lockdown administrator cannot be demoted.');
}
db.prepare(`
UPDATE administrators
SET username = ?, password_hash = ?, discord_id = ?, role = ?, updated_at = ?
WHERE id = ?
`).run(username, passwordHash, discordId, role, Date.now(), Number(id));
writeAudit(actor, 'administrator.updated', { administratorId: Number(id), username, role, passwordChanged: changes.passwordHash != null });
});
function updateAdministrator(id, changes, actor) {
updateAdministratorTransaction(id, changes || {}, actor || 'system');
return listAdministrators().find((entry) => entry.id === Number(id));
}
const deleteAdministratorTransaction = db.transaction((id, actor) => {
const current = db.prepare('SELECT * FROM administrators WHERE id = ?').get(Number(id));
if (!current) throw new Error('Administrator not found.');
if (current.role === 'lockdown' && countLockdownAdministrators() <= 1) {
throw new Error('The final lockdown administrator cannot be removed.');
}
db.prepare('DELETE FROM administrators WHERE id = ?').run(Number(id));
writeAudit(actor, 'administrator.deleted', { administratorId: Number(id), username: current.username, role: current.role });
});
function deleteAdministrator(id, actor = 'system') {
deleteAdministratorTransaction(id, actor);
}
function isSetupComplete() {
return countLockdownAdministrators() > 0;
}
function listAuditEvents({ limit = 200 } = {}) {
const safeLimit = Math.max(1, Math.min(1000, Math.floor(Number(limit) || 200)));
return db.prepare(`
SELECT id, created_at, actor, action, details_json
FROM administrative_audit_events
ORDER BY id DESC
LIMIT ?
`).all(safeLimit).map((row) => ({
id: Number(row.id),
createdAt: Number(row.created_at),
actor: row.actor,
action: row.action,
details: JSON.parse(row.details_json),
}));
}
function recordAuditEvent(actor, action, details = {}) {
/*
Operational admin services need the same persistent audit trail as
configuration changes, but they must not gain access to the underlying
statement or database handle. This narrow method retains the existing
redacted-details contract at the database boundary.
*/
writeAudit(actor, action, details);
}
const importConfigurationFileTransaction = db.transaction(({ config, administrators, actor, source }) => {
// A setup upload initializes an empty installation; it is deliberately not
// a general-purpose replacement path for a running server's configuration.
if (isSetupComplete()) throw new Error('A configuration file cannot replace an initialized installation.');
const normalized = assertValidConfig(normalizeConfig(config));
const revision = commitRevisionTransaction(normalized, {
expectedRevision: getActiveConfigurationRecord().revision,
actor,
source,
});
administrators.forEach((admin) => createAdministratorTransaction(admin, actor, false));
if (!isSetupComplete()) throw new Error('The configuration file must contain at least one lockdown administrator.');
writeAudit(actor, 'setup.configuration-file-imported', { revision, administratorCount: administrators.length, source });
return revision;
});
function importConfigurationFile(payload) {
return importConfigurationFileTransaction(payload);
}
function backupDatabase(destinationPath) {
/*
SQLite's online backup API produces one coherent database file while the
live WAL-backed connection remains open. The backup service receives only
this narrow operation, never the private database handle.
*/
return db.backup(destinationPath);
}
return {
databasePath,
getActiveConfigurationRecord,
getClientConfiguration,
updateConfiguration,
listConfigurationRevisions,
restoreConfigurationRevision,
listAdministrators,
findAdministratorForAuthentication,
createAdministrator,
updateAdministrator,
deleteAdministrator,
countLockdownAdministrators,
isSetupComplete,
listAuditEvents,
recordAuditEvent,
importConfigurationFile,
backupDatabase,
close: () => db.close(),
};
}
module.exports = {
DEFAULT_DATABASE_PATH,
createConfigurationDatabase,
redactConfiguration,
};
+125
View File
@@ -0,0 +1,125 @@
// Complete Configuration Definition
// Purpose: Assembles service-owned configuration fragments into the one ordered document used by storage, validation, and the admin UI.
// Scope: Controls top-level order and composition only; each owning service defines the meaning, defaults, and schema of its own values.
const { strictObject } = require('./schemaHelpers');
const sessionConfiguration = require('../services/sessionService/configuration');
const interInstance = require('../services/interInstanceService/configuration');
const llmCommentary = require('../services/llmCommentaryService/configuration');
const overseerControl = require('../services/overseerControlService/configuration');
const barcodeGames = require('../services/barcodeGameService/configuration');
const media = require('../services/mediaMtxService/configuration');
const bandwidthSavings = require('../helpers/bandwidthSavings.configuration');
const audioForward = require('../services/audioForwardService/configuration');
const audioLevels = require('../services/audioLevelsService/configuration');
const homeAssistant = require('../services/homeAssistantService/configuration');
const roomCameras = require('../services/roomCameraService/configuration');
const ptzCamera = require('../services/ptzCameraService/configuration');
const kinect = require('../services/kinectService/configuration');
const balanceBoard = require('../services/balanceBoardService/configuration');
const buttonBox = require('../services/buttonBoxService/configuration');
const barcodeScanner = require('../services/barcodeScannerService/configuration');
const commands = require('../services/operatorCommandService/configuration');
const discord = require('../services/discordBotService/configuration');
const fleetReports = require('../services/fleetReportService/configuration');
/*
Object property order is preserved by JSON serialization and JSON Schema
consumers. Keeping this explicit list in legacy-YAML order makes the generic
admin form predictable without creating a second frontend ordering system.
The session service owns three non-adjacent public-presentation values, so
those fragments are placed independently at their historical positions.
*/
const definitions = [
sessionConfiguration.publicUrl,
sessionConfiguration.timezone,
interInstance,
llmCommentary,
overseerControl,
barcodeGames,
media,
bandwidthSavings,
audioForward,
audioLevels,
homeAssistant,
roomCameras,
ptzCamera,
kinect,
balanceBoard,
buttonBox,
barcodeScanner,
commands,
discord,
sessionConfiguration.socials,
sessionConfiguration.driverAd,
fleetReports,
];
const defaultConfig = Object.fromEntries(
definitions.map(({ key, defaultValue }) => [key, defaultValue]),
);
const properties = Object.fromEntries(
definitions.map(({ key, schema }) => [key, schema]),
);
const rootSchema = strictObject(properties, {
title: 'Configuration',
description: 'Complete server configuration. Changes are validated, saved as one revision, and applied live by reloading affected services.',
required: definitions.map(({ key }) => key),
});
function collectFeatureDefinitions(definition, parentPath = []) {
const configPath = [...parentPath, definition.key];
const features = [];
if (definition.feature === true) {
/*
A feature declaration is intentionally only a boolean marker. Its public
name is the configuration item's key and its value is that item's own
enabled field, so a service cannot introduce a second enablement rule in
metadata. Failing during definition assembly catches an invalid marker at
startup instead of publishing an undefined capability to browsers.
*/
if (definition.schema?.properties?.enabled?.type !== 'boolean') {
throw new Error(`Configuration feature ${definition.key} must define a boolean enabled field.`);
}
features.push({ key: definition.key, path: [...configPath, 'enabled'] });
}
const nestedDefinitions = Array.isArray(definition.nestedDefinitions)
? definition.nestedDefinitions
: [];
nestedDefinitions.forEach((nestedDefinition) => {
features.push(...collectFeatureDefinitions(nestedDefinition, configPath));
});
return features;
}
/*
This derived list replaces the old hand-maintained feature registry. Top-level
and nested configuration owners opt in beside their schema, while this module
only preserves their already-declared document paths.
*/
const featureDefinitions = definitions.flatMap((definition) => collectFeatureDefinitions(definition));
function collectWriteOnlyPaths(schema, prefix = '') {
/*
Secrets are declared once, beside the service field that consumes them.
Walking object properties produces the dotted paths needed for redaction
and update handling without maintaining a parallel secret registry.
*/
if (!schema || typeof schema !== 'object') return [];
if (schema.writeOnly === true) return prefix ? [prefix] : [];
if (schema.type !== 'object' || !schema.properties) return [];
return Object.entries(schema.properties).flatMap(([key, childSchema]) => (
collectWriteOnlyPaths(childSchema, prefix ? `${prefix}.${key}` : key)
));
}
const secretPaths = collectWriteOnlyPaths(rootSchema);
module.exports = {
definitions,
defaultConfig,
rootSchema,
secretPaths,
featureDefinitions,
};

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