mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8836f7d65 | ||
|
|
f77a969098 | ||
|
|
5ab62c3633 | ||
|
|
fab0673d9e | ||
|
|
a5ec1dcb2d | ||
|
|
6ff60f7d0e | ||
|
|
ea16e2c67a | ||
|
|
9615423e06 | ||
|
|
93232e2a54 | ||
|
|
271197f33c | ||
|
|
8e7d31dcdd | ||
|
|
4f87a0eec2 | ||
|
|
cc2e85d174 | ||
|
|
737760ff56 | ||
|
|
0f0f82e5f6 | ||
|
|
3807b8bb13 | ||
|
|
9f2f819bbd | ||
|
|
60329e1036 | ||
|
|
4430517af5 | ||
|
|
b24d453ad1 | ||
|
|
b5e8d775a2 | ||
|
|
e5830533ba | ||
|
|
dfd674a447 | ||
|
|
bd65f93756 | ||
|
|
0083c887f4 | ||
|
|
70f71b2d1e | ||
|
|
c8742dbbd6 | ||
|
|
6a6dec5540 | ||
|
|
6c69c583c5 | ||
|
|
9702cf0f82 | ||
|
|
a55257dd51 | ||
|
|
8d1761afe2 | ||
|
|
c7c52eb39c | ||
|
|
28fcbad902 | ||
|
|
208b89fd7f | ||
|
|
15a60a58e6 | ||
|
|
3ebd1c7f9c | ||
|
|
7fdcb53041 | ||
|
|
a2dde4fd3d | ||
|
|
8c5d98bed6 | ||
|
|
1aecab66e7 | ||
|
|
ef18ef89e0 | ||
|
|
46bbe5c531 | ||
|
|
dc8267073b | ||
|
|
eb0db3508f | ||
|
|
6aee49bfdb | ||
|
|
a9428d3d72 | ||
|
|
30e961d727 | ||
|
|
a2fbbc100e | ||
|
|
42a7cefeba | ||
|
|
7272c8b4fa | ||
|
|
0e1ad8c6a0 | ||
|
|
09c1257578 | ||
|
|
f3b349bb4a | ||
|
|
81f52c29d8 | ||
|
|
d000b8f4f8 | ||
|
|
0f9bed9c99 | ||
|
|
8642fbac3c | ||
|
|
3a26b4871a | ||
|
|
b26daed93f | ||
|
|
fb9565faf9 | ||
|
|
358aa0b1d6 | ||
|
|
0ecee03f64 | ||
|
|
90d4f778a5 | ||
|
|
b261a4bfa2 | ||
|
|
dd1fd1e167 | ||
|
|
eba4b1dc1d | ||
|
|
cacd125fcb | ||
|
|
8a4162683f | ||
|
|
387a5f47d7 | ||
|
|
5b6947d92c | ||
|
|
cd8f8816c9 | ||
|
|
b86eec88f8 | ||
|
|
512adfc1e0 | ||
|
|
fe33f27bd0 | ||
|
|
afe8ffc62e | ||
|
|
4e6e9e3021 | ||
|
|
f9461433af | ||
|
|
b7d421c489 | ||
|
|
c6b2843150 | ||
|
|
b451849c02 | ||
|
|
955f6f213d | ||
|
|
c9842d7ba5 | ||
|
|
d7fb15d891 | ||
|
|
1cd0e05b4a | ||
|
|
7927768731 | ||
|
|
d9cb0c76b6 | ||
|
|
351cb24458 | ||
|
|
679563862d | ||
|
|
8655cde0f1 | ||
|
|
12090f23be | ||
|
|
557b4b81a2 | ||
|
|
0add90714b | ||
|
|
fe64ec7758 | ||
|
|
10f71edaf1 | ||
|
|
e97d4056fa | ||
|
|
c228bb107f | ||
|
|
06aeca660b | ||
|
|
4bd228547a | ||
|
|
35561495b4 | ||
|
|
8a9205b5b7 | ||
|
|
a6c569ada4 | ||
|
|
0d352d326d | ||
|
|
7bc08af160 | ||
|
|
9177e53fbf | ||
|
|
5be5ad3b17 | ||
|
|
99bc00e96b | ||
|
|
002b174259 | ||
|
|
e28ccc5e66 | ||
|
|
efae430d65 | ||
|
|
0c9df78070 |
+6
-1
@@ -21,15 +21,20 @@ server/data/admin-reason.json
|
|||||||
server/data/buttonbox-state.json
|
server/data/buttonbox-state.json
|
||||||
server/data/barcode-tts-cache/
|
server/data/barcode-tts-cache/
|
||||||
server/data/rover-odometers.json
|
server/data/rover-odometers.json
|
||||||
|
server/data/mediamtx.yml
|
||||||
webui/package-lock.json
|
webui/package-lock.json
|
||||||
!server/data/
|
!server/data/
|
||||||
!server/data/barcode-registry.json
|
!server/data/barcode-registry.json
|
||||||
webui/src/config/analytics.jsx
|
webui/src/config/analytics.jsx
|
||||||
webui/src/config/driverAnalytics.json
|
webui/src/config/driverAnalytics.json
|
||||||
webui/src/config/analytics.html
|
server/data/analytics.html
|
||||||
plans/barcodegames.txt
|
plans/barcodegames.txt
|
||||||
.gitignore
|
.gitignore
|
||||||
server/data/identity.sqlite
|
server/data/identity.sqlite
|
||||||
server/data/barcode-games.json
|
server/data/barcode-games.json
|
||||||
server/data/identity.sqlite-shm
|
server/data/identity.sqlite-shm
|
||||||
server/data/identity.sqlite-wal
|
server/data/identity.sqlite-wal
|
||||||
|
server/src/services/balanceBoardService/native/balance_board_worker
|
||||||
|
server/data/fleet-reports.sqlite
|
||||||
|
server/data/fleet-reports.sqlite-shm
|
||||||
|
server/data/fleet-reports.sqlite-wal
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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 |
@@ -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
@@ -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.
|
||||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The
|
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. The forward URL is
|
||||||
# forward URL is data produced by roverd, and treating it as shell code would
|
# data produced by roverd and must never be interpreted as executable shell code.
|
||||||
# break on normal SRT query-string characters such as '&'.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local content=""
|
local content=""
|
||||||
|
|
||||||
@@ -95,6 +94,9 @@ run_pipeline() {
|
|||||||
-flags low_delay
|
-flags low_delay
|
||||||
-analyzeduration 200k
|
-analyzeduration 200k
|
||||||
-probesize 32k
|
-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}"
|
-i "${ROVERD_AUDIO_PLAYBACK_FORWARD_URL}"
|
||||||
-vn
|
-vn
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. SRT URLs
|
# Load KEY=VALUE pairs from media.env without evaluating shell syntax. URLs are data;
|
||||||
# contain characters such as '&' and '#!', so sourcing this file would treat a
|
# sourcing this file would unnecessarily treat server-provided values as shell code.
|
||||||
# data file as code and can split a valid URL into shell control operators.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local content=""
|
local content=""
|
||||||
|
|
||||||
@@ -88,6 +87,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
run_pipeline() {
|
||||||
|
local -a pipeline_statuses=()
|
||||||
local ffmpeg_args=(
|
local ffmpeg_args=(
|
||||||
-hide_banner
|
-hide_banner
|
||||||
-loglevel warning
|
-loglevel warning
|
||||||
@@ -123,13 +123,14 @@ run_pipeline() {
|
|||||||
-frame_duration 20
|
-frame_duration 20
|
||||||
-compression_level 0
|
-compression_level 0
|
||||||
|
|
||||||
# Mirror the video publisher's MPEG-TS low-latency settings. Without
|
# RTSP carries the existing Opus stream directly, avoiding MediaMTX's costly
|
||||||
# these, ffmpeg is allowed to hold packets for mux timing, which is
|
# MPEG-TS demux without changing microphone capture or encoding quality. TCP is
|
||||||
# exactly the wrong tradeoff for live rover feedback.
|
# required for the same reliable local-network behavior as the video publisher.
|
||||||
-flush_packets 1
|
-flush_packets 1
|
||||||
-muxdelay 0
|
-muxdelay 0
|
||||||
-muxpreload 0
|
-muxpreload 0
|
||||||
-f mpegts
|
-f rtsp
|
||||||
|
-rtsp_transport tcp
|
||||||
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
|
"${ROVERD_AUDIO_CAPTURE_PUBLISH_URL}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,6 +147,17 @@ run_pipeline() {
|
|||||||
# latency compared with the old 65,536-byte buffer.
|
# 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 \
|
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[@]}"
|
| "${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
|
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
||||||
@@ -154,6 +166,6 @@ while true; do
|
|||||||
if run_pipeline; then
|
if run_pipeline; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ if [[ ! -f "$ENV_FILE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load roverd's generated media.env as data instead of sourcing it as shell.
|
# Load roverd's generated media.env as data instead of sourcing it as shell. URLs are
|
||||||
# The SRT publish URL contains normal query-string characters like '&' and '#!',
|
# configuration data, so evaluating the file would be both fragile and unnecessary.
|
||||||
# so evaluating the file would be both fragile and unnecessary.
|
|
||||||
load_env_file() {
|
load_env_file() {
|
||||||
local content=""
|
local content=""
|
||||||
|
|
||||||
@@ -103,6 +102,8 @@ if [[ "${ROVERD_VIDEO_INVERT}" -ne 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
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}" \
|
"${FFMPEG_BIN_PATH}" \
|
||||||
-hide_banner \
|
-hide_banner \
|
||||||
-loglevel warning \
|
-loglevel warning \
|
||||||
@@ -130,7 +131,8 @@ run_pipeline() {
|
|||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
-muxdelay 0 \
|
-muxdelay 0 \
|
||||||
-muxpreload 0 \
|
-muxpreload 0 \
|
||||||
-f mpegts \
|
-f rtsp \
|
||||||
|
-rtsp_transport tcp \
|
||||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+37
@@ -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"
|
||||||
@@ -110,6 +110,10 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
run_pipeline() {
|
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}" \
|
"${LIBCAMERA_BIN_PATH}" \
|
||||||
--inline \
|
--inline \
|
||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
@@ -142,7 +146,8 @@ run_pipeline() {
|
|||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
-muxdelay 0 \
|
-muxdelay 0 \
|
||||||
-muxpreload 0 \
|
-muxpreload 0 \
|
||||||
-f mpegts \
|
-f rtsp \
|
||||||
|
-rtsp_transport tcp \
|
||||||
"${ROVERD_VIDEO_PUBLISH_URL}"
|
"${ROVERD_VIDEO_PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ write_media_env_placeholder() {
|
|||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
ROVERD_VIDEO_ENABLE=1
|
ROVERD_VIDEO_ENABLE=1
|
||||||
ROVERD_VIDEO_PUBLISHER=pi-libcamera
|
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_DEVICE=
|
||||||
ROVERD_VIDEO_INPUT_FORMAT=
|
ROVERD_VIDEO_INPUT_FORMAT=
|
||||||
ROVERD_VIDEO_WIDTH=640
|
ROVERD_VIDEO_WIDTH=640
|
||||||
@@ -23,13 +23,13 @@ ROVERD_VIDEO_BITRATE=2000000
|
|||||||
ROVERD_VIDEO_INVERT=1
|
ROVERD_VIDEO_INVERT=1
|
||||||
ROVERD_VIDEO_SENSOR_MODE=1296:972
|
ROVERD_VIDEO_SENSOR_MODE=1296:972
|
||||||
ROVERD_AUDIO_CAPTURE_ENABLE=0
|
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_DEVICE=hw:0,0
|
||||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
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_DEVICE=forward
|
||||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
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
|
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.
|
# Managed by roverd; placeholder values will be overwritten at runtime.
|
||||||
ROVERD_VIDEO_ENABLE=1
|
ROVERD_VIDEO_ENABLE=1
|
||||||
ROVERD_VIDEO_PUBLISHER=debian-laptop-v4l2
|
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_DEVICE=/dev/video0
|
||||||
ROVERD_VIDEO_INPUT_FORMAT=mjpeg
|
ROVERD_VIDEO_INPUT_FORMAT=mjpeg
|
||||||
ROVERD_VIDEO_WIDTH=640
|
ROVERD_VIDEO_WIDTH=640
|
||||||
@@ -53,13 +53,13 @@ ROVERD_VIDEO_BITRATE=2000000
|
|||||||
ROVERD_VIDEO_INVERT=0
|
ROVERD_VIDEO_INVERT=0
|
||||||
ROVERD_VIDEO_SENSOR_MODE=
|
ROVERD_VIDEO_SENSOR_MODE=
|
||||||
ROVERD_AUDIO_CAPTURE_ENABLE=1
|
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_DEVICE=default
|
||||||
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
ROVERD_AUDIO_CAPTURE_SAMPLE_RATE=48000
|
||||||
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
ROVERD_AUDIO_CAPTURE_CHANNELS=2
|
||||||
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
ROVERD_AUDIO_CAPTURE_BITRATE=510000
|
||||||
ROVERD_AUDIO_PLAYBACK_ENABLE=1
|
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_DEVICE=forward
|
||||||
ROVERD_AUDIO_PLAYBACK_NORMALIZE=1
|
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
|
ROVERD_AUDIO_PLAYBACK_NORMALIZE_FILTER=dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled
|
||||||
|
|||||||
+57
-50
@@ -3,9 +3,11 @@ package roverd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -77,10 +79,10 @@ type HornConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MediaConfig struct {
|
type MediaConfig struct {
|
||||||
// PublishPort is shared by the derived video, microphone, and forwarded-audio
|
// RTSPPort is shared by the derived video, microphone, and forwarded-audio
|
||||||
// SRT URLs. Keeping it at this level prevents each nested block from needing
|
// 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.
|
// 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"`
|
Manage bool `yaml:"manage" json:"manage"`
|
||||||
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
|
||||||
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
HealthInterval Duration `yaml:"healthInterval" json:"-"`
|
||||||
@@ -93,10 +95,12 @@ type VideoMediaConfig struct {
|
|||||||
// Publisher selects the installed publisher script/pipeline family. The
|
// Publisher selects the installed publisher script/pipeline family. The
|
||||||
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
|
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
|
||||||
// without changing the server-facing media shape again.
|
// without changing the server-facing media shape again.
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Service string `yaml:"service" json:"service,omitempty"`
|
Service string `yaml:"service" json:"service,omitempty"`
|
||||||
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,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"`
|
Device string `yaml:"device" json:"device,omitempty"`
|
||||||
InputFormat string `yaml:"inputFormat" json:"-"`
|
InputFormat string `yaml:"inputFormat" json:"-"`
|
||||||
Width int `yaml:"width" json:"-"`
|
Width int `yaml:"width" json:"-"`
|
||||||
@@ -111,9 +115,10 @@ type AudioCaptureConfig struct {
|
|||||||
// AudioCapture describes the rover microphone stream that browsers can
|
// AudioCapture describes the rover microphone stream that browsers can
|
||||||
// subscribe to as "<rover>-audio". A disabled capture block still has
|
// subscribe to as "<rover>-audio". A disabled capture block still has
|
||||||
// normalized defaults so enabling it only requires flipping enabled: true.
|
// normalized defaults so enabling it only requires flipping enabled: true.
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Service string `yaml:"service" json:"service,omitempty"`
|
Service string `yaml:"service" json:"service,omitempty"`
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,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"`
|
Device string `yaml:"device" json:"device,omitempty"`
|
||||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
SampleRate int `yaml:"sampleRate" json:"-"`
|
||||||
Channels int `yaml:"channels" 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
|
// 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
|
// for the rover listener, while the server converts it to publish mode when
|
||||||
// it needs to inject audio.
|
// it needs to inject audio.
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Service string `yaml:"service" json:"service,omitempty"`
|
Service string `yaml:"service" json:"service,omitempty"`
|
||||||
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,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"`
|
Device string `yaml:"device" json:"device,omitempty"`
|
||||||
Normalize bool `yaml:"normalize" json:"-"`
|
Normalize bool `yaml:"normalize" json:"-"`
|
||||||
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
|
||||||
@@ -230,7 +236,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Media: MediaConfig{
|
Media: MediaConfig{
|
||||||
PublishPort: 9000,
|
RTSPPort: 8554,
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||||
Video: VideoMediaConfig{
|
Video: VideoMediaConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -349,8 +355,8 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if cfg.BRC.GPIOChip == "" {
|
if cfg.BRC.GPIOChip == "" {
|
||||||
cfg.BRC.GPIOChip = "gpiochip0"
|
cfg.BRC.GPIOChip = "gpiochip0"
|
||||||
}
|
}
|
||||||
if cfg.Media.PublishPort <= 0 {
|
if cfg.Media.RTSPPort <= 0 {
|
||||||
cfg.Media.PublishPort = 9000
|
cfg.Media.RTSPPort = 8554
|
||||||
}
|
}
|
||||||
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
|
||||||
return nil, fmt.Errorf("media: %w", err)
|
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
|
file is written. This keeps the Pi behavior stable while making laptop
|
||||||
and future publisher variants explicit configuration choices.
|
and future publisher variants explicit configuration choices.
|
||||||
*/
|
*/
|
||||||
if cfg.PublishPort <= 0 {
|
if cfg.RTSPPort <= 0 {
|
||||||
cfg.PublishPort = 9000
|
cfg.RTSPPort = 8554
|
||||||
}
|
}
|
||||||
if cfg.HealthInterval.Duration <= 0 {
|
if cfg.HealthInterval.Duration <= 0 {
|
||||||
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
|
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)
|
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)
|
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 fmt.Errorf("audioPlayback: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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 == "" {
|
if cfg.Service == "" {
|
||||||
cfg.Service = "video-publisher.service"
|
cfg.Service = "video-publisher.service"
|
||||||
}
|
}
|
||||||
@@ -476,17 +482,19 @@ func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName
|
|||||||
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
|
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
|
||||||
cfg.SensorMode = "1296:972"
|
cfg.SensorMode = "1296:972"
|
||||||
}
|
}
|
||||||
if cfg.PublishURL == "" {
|
/*
|
||||||
derived, err := derivePublishURL(serverURL, roverName, publishPort)
|
Always derive this endpoint. Older rover configs can contain an explicit SRT publishUrl;
|
||||||
if err != nil {
|
honoring it after a binary update would silently leave that rover on the old transport.
|
||||||
return fmt.Errorf("derive publishUrl: %w", err)
|
*/
|
||||||
}
|
derived, err := derivePublishURL(serverURL, roverName, rtspPort)
|
||||||
cfg.PublishURL = derived
|
if err != nil {
|
||||||
|
return fmt.Errorf("derive publishUrl: %w", err)
|
||||||
}
|
}
|
||||||
|
cfg.PublishURL = derived
|
||||||
return nil
|
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 == "" {
|
if cfg.Service == "" {
|
||||||
cfg.Service = "audio-only-publisher.service"
|
cfg.Service = "audio-only-publisher.service"
|
||||||
}
|
}
|
||||||
@@ -502,17 +510,15 @@ func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, rover
|
|||||||
if cfg.Bitrate <= 0 {
|
if cfg.Bitrate <= 0 {
|
||||||
cfg.Bitrate = 510000
|
cfg.Bitrate = 510000
|
||||||
}
|
}
|
||||||
if cfg.PublishURL == "" {
|
derived, err := derivePublishURL(serverURL, roverName+"-audio", rtspPort)
|
||||||
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("derive publishUrl: %w", err)
|
||||||
return fmt.Errorf("derive publishUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.PublishURL = derived
|
|
||||||
}
|
}
|
||||||
|
cfg.PublishURL = derived
|
||||||
return nil
|
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 == "" {
|
if cfg.Service == "" {
|
||||||
cfg.Service = "audio-forward-listener.service"
|
cfg.Service = "audio-forward-listener.service"
|
||||||
}
|
}
|
||||||
@@ -522,13 +528,11 @@ func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, rov
|
|||||||
if cfg.NormalizeFilter == "" {
|
if cfg.NormalizeFilter == "" {
|
||||||
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
|
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", rtspPort)
|
||||||
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("derive forwardUrl: %w", err)
|
||||||
return fmt.Errorf("derive forwardUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.ForwardURL = derived
|
|
||||||
}
|
}
|
||||||
|
cfg.ForwardURL = derived
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,20 +581,17 @@ func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
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) {
|
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 == "" {
|
if streamName == "" {
|
||||||
return "", errors.New("missing stream name for publishUrl")
|
return "", errors.New("missing stream name for publishUrl")
|
||||||
}
|
}
|
||||||
if mode == "" {
|
|
||||||
mode = "publish"
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(serverURL)
|
parsed, err := url.Parse(serverURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -600,10 +601,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
|
|||||||
return "", errors.New("serverUrl missing host")
|
return "", errors.New("serverUrl missing host")
|
||||||
}
|
}
|
||||||
if port <= 0 {
|
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)
|
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}$`)
|
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||||
|
|||||||
+93
-6
@@ -3,6 +3,7 @@ package roverd
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
hostStatsInterval = 5 * time.Second
|
hostStatsInterval = 1 * time.Second
|
||||||
rootFilesystem = "/"
|
rootFilesystem = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +64,24 @@ type WiFiStats struct {
|
|||||||
TXBytes *uint64 `json:"txBytes,omitempty"`
|
TXBytes *uint64 `json:"txBytes,omitempty"`
|
||||||
RXPackets *uint64 `json:"rxPackets,omitempty"`
|
RXPackets *uint64 `json:"rxPackets,omitempty"`
|
||||||
TXPackets *uint64 `json:"txPackets,omitempty"`
|
TXPackets *uint64 `json:"txPackets,omitempty"`
|
||||||
|
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
|
||||||
|
UploadMbps *float64 `json:"uploadMbps,omitempty"`
|
||||||
InactiveMs *int `json:"inactiveMs,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
|
// CollectHostStats gathers every source independently so one missing kernel
|
||||||
@@ -370,12 +388,81 @@ func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The interface is used only to ask iw about the active connection. It is
|
// The interface is used only for local collection. It is not copied into
|
||||||
// not copied into WiFiStats because the UI does not need to expose it.
|
// WiFiStats because the UI does not need to expose Linux device names.
|
||||||
if err := enrichWiFiWithIW(ctx, iface, stats); err != nil {
|
iwErr := enrichWiFiWithIW(ctx, iface, stats)
|
||||||
return stats, err
|
|
||||||
|
// 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) {
|
func readWirelessStats() (string, *WiFiStats, error) {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputCalculatesMbpsFromActualElapsedTime(t *testing.T) {
|
||||||
|
startedAt := time.Unix(100, 0)
|
||||||
|
previous := &networkRateSample{rxBytes: 1_000, txBytes: 2_000, sampledAt: startedAt}
|
||||||
|
rxBytes := uint64(2_001_000)
|
||||||
|
txBytes := uint64(1_002_000)
|
||||||
|
stats := &WiFiStats{
|
||||||
|
RXBytes: &rxBytes,
|
||||||
|
TXBytes: &txBytes,
|
||||||
|
networkSampledAt: startedAt.Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, previous)
|
||||||
|
|
||||||
|
if stats.DownloadMbps == nil || *stats.DownloadMbps != 8.0 {
|
||||||
|
t.Fatalf("expected 8.0 Mbps download, got %v", stats.DownloadMbps)
|
||||||
|
}
|
||||||
|
if stats.UploadMbps == nil || *stats.UploadMbps != 4.0 {
|
||||||
|
t.Fatalf("expected 4.0 Mbps upload, got %v", stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
|
||||||
|
t.Fatalf("expected current counters to become the next baseline, got %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputFirstSampleOnlyEstablishesBaseline(t *testing.T) {
|
||||||
|
rxBytes := uint64(100)
|
||||||
|
txBytes := uint64(200)
|
||||||
|
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: time.Unix(100, 0)}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, nil)
|
||||||
|
|
||||||
|
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||||
|
t.Fatalf("expected no rates for the first sample, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
t.Fatal("expected the first valid sample to establish a baseline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputCounterResetEstablishesNewBaseline(t *testing.T) {
|
||||||
|
startedAt := time.Unix(100, 0)
|
||||||
|
previous := &networkRateSample{rxBytes: 10_000, txBytes: 20_000, sampledAt: startedAt}
|
||||||
|
rxBytes := uint64(10)
|
||||||
|
txBytes := uint64(20)
|
||||||
|
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: startedAt.Add(time.Second)}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, previous)
|
||||||
|
|
||||||
|
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||||
|
t.Fatalf("expected no rates after a counter reset, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
|
||||||
|
t.Fatalf("expected reset counters to become the new baseline, got %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputInvalidElapsedTimeEstablishesNewBaseline(t *testing.T) {
|
||||||
|
sampledAt := time.Unix(100, 0)
|
||||||
|
previous := &networkRateSample{rxBytes: 100, txBytes: 200, sampledAt: sampledAt}
|
||||||
|
rxBytes := uint64(200)
|
||||||
|
txBytes := uint64(300)
|
||||||
|
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: sampledAt}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, previous)
|
||||||
|
|
||||||
|
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||||
|
t.Fatalf("expected no rates with zero elapsed time, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil || next.sampledAt != sampledAt {
|
||||||
|
t.Fatalf("expected invalid timing sample to become the new baseline, got %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ battery:
|
|||||||
maxWheelSpeed: 350
|
maxWheelSpeed: 350
|
||||||
|
|
||||||
media:
|
media:
|
||||||
publishPort: 9000
|
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||||
|
rtspPort: 8554
|
||||||
manage: true
|
manage: true
|
||||||
healthUrl: ""
|
healthUrl: ""
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ battery:
|
|||||||
urgent: 1650
|
urgent: 1650
|
||||||
maxWheelSpeed: 350
|
maxWheelSpeed: 350
|
||||||
media:
|
media:
|
||||||
publishPort: 9000
|
# Media URLs are derived from serverUrl's hostname, this port, and the rover name.
|
||||||
|
rtspPort: 8554
|
||||||
manage: true
|
manage: true
|
||||||
healthUrl: ""
|
healthUrl: ""
|
||||||
healthInterval: 30s
|
healthInterval: 30s
|
||||||
@@ -25,7 +26,6 @@ media:
|
|||||||
enabled: true
|
enabled: true
|
||||||
service: video-publisher.service
|
service: video-publisher.service
|
||||||
publisher: pi-libcamera
|
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
|
width: 640
|
||||||
height: 480
|
height: 480
|
||||||
fps: 30
|
fps: 30
|
||||||
@@ -36,7 +36,6 @@ media:
|
|||||||
audioCapture:
|
audioCapture:
|
||||||
enabled: false
|
enabled: false
|
||||||
service: audio-only-publisher.service
|
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
|
device: hw:0,0
|
||||||
sampleRate: 48000
|
sampleRate: 48000
|
||||||
channels: 2
|
channels: 2
|
||||||
@@ -44,7 +43,6 @@ media:
|
|||||||
audioPlayback:
|
audioPlayback:
|
||||||
enabled: true
|
enabled: true
|
||||||
service: audio-forward-listener.service
|
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
|
device: forward
|
||||||
normalize: true
|
normalize: true
|
||||||
cameraServo:
|
cameraServo:
|
||||||
|
|||||||
@@ -531,14 +531,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
|
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
|
||||||
|
var previousNetworkSample *networkRateSample
|
||||||
|
|
||||||
send := func() bool {
|
send := func() bool {
|
||||||
// Host stats are collected on demand so each outbound message describes
|
// Host stats are collected on demand so each outbound message describes
|
||||||
// the current Pi state. Collection failures are encoded into the stats
|
// the current Pi state. Collection failures are encoded into the stats
|
||||||
// payload, which keeps this telemetry path from closing the rover socket.
|
// 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{
|
msg := hostStatsMessage{
|
||||||
Type: "hostStats",
|
Type: "hostStats",
|
||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
Stats: CollectHostStats(ctx),
|
Stats: stats,
|
||||||
}
|
}
|
||||||
if err := writeJSON(ctx, conn, msg); err != nil {
|
if err := writeJSON(ctx, conn, msg); err != nil {
|
||||||
c.log.Printf("host stats send failed: %v", err)
|
c.log.Printf("host stats send failed: %v", err)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Rover Audio Forward Listener (SRT -> ALSA)
|
Description=Rover Audio Forward Listener (RTSP/TCP -> ALSA)
|
||||||
After=network-online.target roverd.service
|
After=network-online.target roverd.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Rover Audio Publisher (ALSA -> SRT)
|
Description=Rover Audio Publisher (ALSA -> RTSP/TCP)
|
||||||
After=network-online.target roverd.service
|
After=network-online.target roverd.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Rover Debian Laptop Video Publisher (V4L2 -> SRT)
|
Description=Rover Debian Laptop Video Publisher (V4L2 -> RTSP/TCP)
|
||||||
After=network-online.target roverd.service
|
After=network-online.target roverd.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Multi-Roomba rover control agent
|
Description=Multi-Roomba rover control agent
|
||||||
After=network-online.target mediamtx.service
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Rover Video Publisher (libcamera -> SRT)
|
Description=Rover Video Publisher (libcamera -> RTSP/TCP)
|
||||||
After=network-online.target roverd.service
|
After=network-online.target roverd.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
- not allowed
|
- not allowed
|
||||||
- snapshots
|
- snapshots
|
||||||
- non-turn video
|
- 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)
|
- 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
|
- external spectator video
|
||||||
- snapshots (external spectators are only allowed snapshots)
|
- snapshots (external spectators are only allowed snapshots)
|
||||||
- live (external spectators can get full video)
|
- live (external spectators can get full video)
|
||||||
@@ -23,7 +24,9 @@
|
|||||||
```yaml
|
```yaml
|
||||||
bandwidthSavings:
|
bandwidthSavings:
|
||||||
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
|
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
|
externalSpectatorVideo: "snapshots" # snapshots | live
|
||||||
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
|
externalSpectatorAccess: "on" # off | on | verifiedOnly | admin
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -51,8 +51,15 @@ barcodeGames:
|
|||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
# Example: http://192.168.0.86:8889/video
|
# Example: http://media-server.local:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://media-server.local:8889/video"
|
||||||
|
# MediaMTX advertises these instance-specific DNS names or IP addresses as WebRTC ICE
|
||||||
|
# candidates. Include every public and LAN address browsers use to reach this server.
|
||||||
|
# The server generates MediaMTX's runtime configuration from this list; never edit a
|
||||||
|
# separate mediamtx.yml for a new installation.
|
||||||
|
additionalHosts:
|
||||||
|
- "rover.example.com"
|
||||||
|
- "media-server.local"
|
||||||
|
|
||||||
bandwidthSavings:
|
bandwidthSavings:
|
||||||
# Duplicate driver-tab handling for the same browser identity.
|
# Duplicate driver-tab handling for the same browser identity.
|
||||||
@@ -60,10 +67,21 @@ bandwidthSavings:
|
|||||||
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||||
# notAllowed: every identity is limited to one driver tab
|
# notAllowed: every identity is limited to one driver tab
|
||||||
multiTabProtection: "verifiedOnly"
|
multiTabProtection: "verifiedOnly"
|
||||||
# Live video for users who are attached to a source but do not currently own
|
# Disconnect rover video when its player is outside the viewport or the web
|
||||||
# its active turn. "snapshots" saves upload bandwidth; "live" allows full
|
# page is in a background browser tab. Rover audio is a separate stream and
|
||||||
# video whenever the normal mode/visibility rules allow it.
|
# remains connected. /mini intentionally keeps its existing always-warm video
|
||||||
nonTurnVideo: "snapshots"
|
# behavior regardless of this option.
|
||||||
|
pauseHiddenRoverVideo: false
|
||||||
|
# Video for users who are attached to a source but do not currently own its
|
||||||
|
# active turn. "snapshots" saves upload bandwidth; "live" allows full video
|
||||||
|
# whenever the normal mode/visibility rules allow it.
|
||||||
|
nonTurnVideo:
|
||||||
|
mode: "snapshots"
|
||||||
|
# Snapshot mode activates only when controllable users exceed this number.
|
||||||
|
# A controllable user is attached to a rover or PTZ as operator/queue, not a
|
||||||
|
# plain spectator. 0 preserves always-on non-turn snapshots once anyone is
|
||||||
|
# actually attached to a controllable source.
|
||||||
|
userThreshold: 0
|
||||||
# Live video for spectators outside the local network. Local spectators are
|
# Live video for spectators outside the local network. Local spectators are
|
||||||
# not restricted by this switch because LAN traffic is not the upload limit.
|
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||||
externalSpectatorVideo: "snapshots"
|
externalSpectatorVideo: "snapshots"
|
||||||
@@ -81,10 +99,15 @@ audioForward:
|
|||||||
maxUploadBytes: 8388608
|
maxUploadBytes: 8388608
|
||||||
|
|
||||||
audioLevels:
|
audioLevels:
|
||||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
# Base multipliers (0.0 - 4.0) applied before any approved user's signed
|
||||||
|
# personal adjustment. The server clamps every final rover gain to this same
|
||||||
|
# hard multiplier range.
|
||||||
hornGain: 1.0
|
hornGain: 1.0
|
||||||
ttsGain: 1.0
|
ttsGain: 1.0
|
||||||
forwardGain: 1.0
|
forwardGain: 1.0
|
||||||
|
# Approved users may move each personal slider this far below or above the
|
||||||
|
# base multiplier. Browser cookies store percentages, never raw multipliers.
|
||||||
|
maxPersonalAdjustmentPercent: 50
|
||||||
|
|
||||||
homeAssistant:
|
homeAssistant:
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -171,6 +194,11 @@ kinect:
|
|||||||
# camera cache; it only gates browser-requested broadcasts.
|
# camera cache; it only gates browser-requested broadcasts.
|
||||||
captureCooldownMs: 10000
|
captureCooldownMs: 10000
|
||||||
|
|
||||||
|
balanceBoard:
|
||||||
|
# The server installer always prepares Bluetooth and the kernel driver. This
|
||||||
|
# switch only starts the service and shows its small live-weight panel.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
buttonBox:
|
buttonBox:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
@@ -216,3 +244,35 @@ socials:
|
|||||||
url: "https://ko-fi.com/your-handle"
|
url: "https://ko-fi.com/your-handle"
|
||||||
icon: "FaCoffee"
|
icon: "FaCoffee"
|
||||||
color: "#29ABE0"
|
color: "#29ABE0"
|
||||||
|
|
||||||
|
# Optional trusted HTML card shown at the bottom of the desktop driver page's
|
||||||
|
# left column. Leave html empty (or omit this section) to hide the card. This
|
||||||
|
# content is sent to driver browsers without sanitization, so only place markup
|
||||||
|
# here that is controlled by the server operator.
|
||||||
|
driverAd:
|
||||||
|
title: "Advertisement"
|
||||||
|
html: |
|
||||||
|
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="https://example.com/ad.png" alt="Advertisement" style="display:block;width:100%;height:auto;">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
# Optional passive fleet telemetry, history, and daily reporting. The collector
|
||||||
|
# observes existing server events and rover sensor frames but never participates
|
||||||
|
# in command, assignment, docking, or safety decisions.
|
||||||
|
fleetReports:
|
||||||
|
enabled: false
|
||||||
|
retention:
|
||||||
|
# Zero retains evidence indefinitely. Set explicit day counts on servers
|
||||||
|
# that prefer bounded storage over complete long-term history.
|
||||||
|
detailedDays: 0
|
||||||
|
minuteSamplesDays: 0
|
||||||
|
battery:
|
||||||
|
enabled: true
|
||||||
|
maximumIntegrationGapSeconds: 5
|
||||||
|
minimumCapacityTestDepthPercent: 60
|
||||||
|
discord:
|
||||||
|
enabled: true
|
||||||
|
sendAt: "08:00"
|
||||||
|
timezone: "America/New_York"
|
||||||
|
privacy:
|
||||||
|
retainChatBodies: true
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -28,6 +28,7 @@ require('./src/services/serverControlService');
|
|||||||
require('./src/services/videoSessions');
|
require('./src/services/videoSessions');
|
||||||
require('./src/services/ptzCameraService');
|
require('./src/services/ptzCameraService');
|
||||||
require('./src/services/videoAuthService');
|
require('./src/services/videoAuthService');
|
||||||
|
require('./src/services/mediaMtxService');
|
||||||
require('./src/services/videoSocketService');
|
require('./src/services/videoSocketService');
|
||||||
require('./src/services/roomCameraService');
|
require('./src/services/roomCameraService');
|
||||||
require('./src/services/roverSnapshotService');
|
require('./src/services/roverSnapshotService');
|
||||||
@@ -46,8 +47,13 @@ require('./src/services/buttonBoxService');
|
|||||||
require('./src/services/barcodeScannerService');
|
require('./src/services/barcodeScannerService');
|
||||||
require('./src/services/barcodeGameService');
|
require('./src/services/barcodeGameService');
|
||||||
require('./src/services/kinectService');
|
require('./src/services/kinectService');
|
||||||
|
require('./src/services/balanceBoardService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
|
// 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');
|
require('./src/services/replayEngineV2');
|
||||||
// Replay delivery is a core service. It must subscribe before the optional
|
// Replay delivery is a core service. It must subscribe before the optional
|
||||||
// Discord feature so web requests always have a local delivery path.
|
// Discord feature so web requests always have a local delivery path.
|
||||||
|
|||||||
+64
-42
@@ -8,14 +8,14 @@ NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/downl
|
|||||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||||
NEOLINK_BIN="/usr/local/bin/neolink"
|
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||||
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
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"
|
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||||
|
BLUETOOTH_OVERRIDE_DIR="/etc/systemd/system/bluetooth.service.d"
|
||||||
|
BLUETOOTH_OVERRIDE="$BLUETOOTH_OVERRIDE_DIR/20-multirover-balance-board.conf"
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "This installer must be run with sudo/root." >&2
|
echo "This installer must be run with sudo/root." >&2
|
||||||
@@ -30,8 +30,9 @@ fi
|
|||||||
TARGET_USER="$SUDO_USER"
|
TARGET_USER="$SUDO_USER"
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
SERVER_DIR="$SCRIPT_DIR"
|
SERVER_DIR="$SCRIPT_DIR"
|
||||||
|
BALANCE_BOARD_NATIVE_DIR="$SCRIPT_DIR/src/services/balanceBoardService/native"
|
||||||
|
BALANCE_BOARD_WORKER="$BALANCE_BOARD_NATIVE_DIR/balance_board_worker"
|
||||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
|
||||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||||
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
||||||
|
|
||||||
@@ -134,7 +135,11 @@ dnf install -y \
|
|||||||
gstreamer1-rtsp-server \
|
gstreamer1-rtsp-server \
|
||||||
libfreenect \
|
libfreenect \
|
||||||
libfreenect-devel \
|
libfreenect-devel \
|
||||||
libusb1-devel >/dev/null
|
libusb1-devel \
|
||||||
|
bluez \
|
||||||
|
wiiuse \
|
||||||
|
wiiuse-devel \
|
||||||
|
libcap >/dev/null
|
||||||
NODE_BIN="$(command -v node)"
|
NODE_BIN="$(command -v node)"
|
||||||
|
|
||||||
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
||||||
@@ -166,12 +171,43 @@ if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
|
|||||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$BALANCE_BOARD_NATIVE_DIR/Makefile" ]]; then
|
||||||
|
echo " Building native Balance Board bridge..."
|
||||||
|
runuser -u "$TARGET_USER" -- bash -c "cd '$BALANCE_BOARD_NATIVE_DIR' && make"
|
||||||
|
if [[ ! -x "$BALANCE_BOARD_WORKER" ]]; then
|
||||||
|
echo "Balance Board worker build did not create $BALANCE_BOARD_WORKER" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Only this small audited bridge needs the management socket used for the
|
||||||
|
# board's raw six-byte pairing PIN and the two reserved HID PSMs used by
|
||||||
|
# front-button reconnects. Never grant either capability to node or the full
|
||||||
|
# multirover service executable.
|
||||||
|
setcap cap_net_admin,cap_net_bind_service+ep "$BALANCE_BOARD_WORKER"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Bluetoothd remains responsible for discovery and the one-time bond, but its
|
||||||
|
# generic input plugin otherwise reserves control PSM 0x11 and interrupt PSM
|
||||||
|
# 0x13 before the Balance Board worker can listen for the board's front-button
|
||||||
|
# reconnect. This dedicated rover server gives those two HID listeners to the
|
||||||
|
# worker; every other BlueZ profile is left enabled. Clearing ExecStart is
|
||||||
|
# required by systemd before replacing the vendor unit's command in a drop-in.
|
||||||
|
install -d -m 0755 "$BLUETOOTH_OVERRIDE_DIR"
|
||||||
|
cat > "$BLUETOOTH_OVERRIDE" <<'EOF'
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/libexec/bluetooth/bluetoothd --noplugin=input
|
||||||
|
EOF
|
||||||
|
chmod 0644 "$BLUETOOTH_OVERRIDE"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable bluetooth.service
|
||||||
|
systemctl restart bluetooth.service
|
||||||
|
|
||||||
tmpdir=$(mktemp -d)
|
tmpdir=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmpdir"' EXIT
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
@@ -220,54 +256,40 @@ if ! verify_google_tts_helper; then
|
|||||||
verify_google_tts_helper
|
verify_google_tts_helper
|
||||||
fi
|
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
|
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
||||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
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"
|
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
|
||||||
|
# Validate the new source of truth before disabling a working legacy service. The validator
|
||||||
|
# performs the same build and YAML serialization as server startup without opening listeners
|
||||||
|
# or leaving a process behind.
|
||||||
|
runuser -u "$TARGET_USER" -- env \
|
||||||
|
SERVER_CONFIG="$CONFIG_PATH" \
|
||||||
|
ROVER_SNAPSHOT_WRITER_BIN="$ROVER_SNAPSHOT_WRITER_BIN" \
|
||||||
|
"$NODE_BIN" "$SERVER_DIR/scripts/validateMediaMtxConfig.js"
|
||||||
|
|
||||||
|
# MediaMTX used to run as its own systemd service with a hand-maintained config in
|
||||||
|
# /etc/mediamtx. Stop it before multirover starts the new child process, otherwise the two
|
||||||
|
# processes race for every media listener. Both commands are deliberately idempotent so an
|
||||||
|
# already-migrated server and a first-time installation follow the same path.
|
||||||
|
echo " Disabling legacy mediamtx.service"
|
||||||
|
systemctl disable --now mediamtx.service 2>/dev/null || true
|
||||||
|
rm -f "$MEDIAMTX_SERVICE"
|
||||||
|
rm -f /etc/mediamtx/mediamtx.yml
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
echo "[4/6] Writing systemd units..."
|
||||||
mkdir -p "$SNAPSHOT_DIR"
|
mkdir -p "$SNAPSHOT_DIR"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
||||||
mkdir -p "$REPLAY_SEGMENT_DIR"
|
mkdir -p "$REPLAY_SEGMENT_DIR"
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
||||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=mediaMTX WebRTC Server
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=$TARGET_USER
|
|
||||||
Group=$TARGET_USER
|
|
||||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
|
||||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
cat > "$MULTIROVER_SERVICE" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Multi-Roomba Rover control server
|
Description=Multi-Roomba Rover control server
|
||||||
After=network-online.target mediamtx.service
|
After=network-online.target bluetooth.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target bluetooth.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=$TARGET_USER
|
User=$TARGET_USER
|
||||||
@@ -277,6 +299,7 @@ Environment=NODE_ENV=production
|
|||||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||||
|
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
@@ -286,21 +309,20 @@ SuccessExitStatus=130 143
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
chmod 644 "$MULTIROVER_SERVICE"
|
||||||
|
|
||||||
echo "[5/6] Enabling services..."
|
echo "[5/6] Enabling services..."
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now mediamtx.service
|
|
||||||
systemctl enable --now multirover.service
|
systemctl enable --now multirover.service
|
||||||
systemctl restart mediamtx.service
|
|
||||||
systemctl restart multirover.service
|
systemctl restart multirover.service
|
||||||
|
|
||||||
echo "[6/6] Done."
|
echo "[6/6] Done."
|
||||||
echo
|
echo
|
||||||
echo "Services installed:"
|
echo "Services installed:"
|
||||||
echo " mediamtx.service (WebRTC fan-out)"
|
echo " multirover.service (Node.js control server with MediaMTX child)"
|
||||||
echo " multirover.service (Node.js control server)"
|
|
||||||
echo
|
echo
|
||||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||||
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
||||||
|
echo "Wii Balance Board direct Bluetooth bridge and front-button listener were installed."
|
||||||
|
echo "Enable balanceBoard in config.yaml, press red Sync once, then use the front button for later wakes."
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"home-assistant-js-websocket": "^3.1.2",
|
"home-assistant-js-websocket": "^3.1.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"kokoro-js": "^1.2.1",
|
"kokoro-js": "^1.2.1",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"obscenity": "^0.4.6",
|
"obscenity": "^0.4.6",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
@@ -4,82 +4,16 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
<link rel="icon" type="image/png" href="/bitmap.png" />
|
||||||
<link rel="apple-touch-icon" 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. -->
|
<!-- 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="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-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<!-- site-metadata:inject -->
|
||||||
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
|
<!-- analytics:inject -->
|
||||||
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
|
<script type="module" crossorigin src="/assets/index-BZ2ymoHR.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css">
|
||||||
<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">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// MediaMTX Configuration Validator
|
||||||
|
// Purpose: Lets the installer validate server-owned MediaMTX inputs before disabling the legacy service.
|
||||||
|
// Scope: Builds and serializes the runtime YAML without starting MediaMTX or changing external state.
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const { loadConfig } = require('../src/helpers/configLoader');
|
||||||
|
const { buildMediaMtxConfig } = require('../src/services/mediaMtxService/config');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const generated = buildMediaMtxConfig({
|
||||||
|
config,
|
||||||
|
serverPort: process.env.PORT || 8080,
|
||||||
|
snapshotWriterPath: process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Serializing is part of validation: it catches values that the builder accepted but js-yaml
|
||||||
|
cannot represent before the installer removes the previous service configuration.
|
||||||
|
*/
|
||||||
|
yaml.dump(generated, { noRefs: true, lineWidth: 120 });
|
||||||
|
process.stdout.write('MediaMTX server configuration is valid\n');
|
||||||
@@ -10,7 +10,11 @@ const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'verifiedOnly', 'a
|
|||||||
|
|
||||||
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||||
multiTabProtection: 'verifiedOnly',
|
multiTabProtection: 'verifiedOnly',
|
||||||
nonTurnVideo: 'snapshots',
|
pauseHiddenRoverVideo: false,
|
||||||
|
nonTurnVideo: Object.freeze({
|
||||||
|
mode: 'snapshots',
|
||||||
|
userThreshold: 0,
|
||||||
|
}),
|
||||||
externalSpectatorVideo: 'snapshots',
|
externalSpectatorVideo: 'snapshots',
|
||||||
externalSpectatorAccess: 'on',
|
externalSpectatorAccess: 'on',
|
||||||
});
|
});
|
||||||
@@ -25,6 +29,32 @@ function normalizeEnum(value, allowed, fallback) {
|
|||||||
return allowed.has(normalized) ? normalized : fallback;
|
return allowed.has(normalized) ? normalized : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback) {
|
||||||
|
/*
|
||||||
|
YAML booleans must stay real booleans. Treating strings such as "false" as
|
||||||
|
truthy would silently enable a bandwidth policy that the operator intended
|
||||||
|
to disable, so invalid values fall back to the documented server default.
|
||||||
|
*/
|
||||||
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonTurnVideo(value) {
|
||||||
|
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||||
|
const threshold = Number(raw.userThreshold);
|
||||||
|
/*
|
||||||
|
userThreshold is intentionally "greater than", not "greater than or equal".
|
||||||
|
A value of 4 means the first four controllable users can keep live non-turn
|
||||||
|
video, and the fifth controllable user activates snapshot saving. Invalid
|
||||||
|
or negative values fall back to zero, which preserves always-on snapshots
|
||||||
|
for any real non-turn participant.
|
||||||
|
*/
|
||||||
|
const userThreshold = Number.isFinite(threshold) ? Math.max(0, Math.floor(threshold)) : 0;
|
||||||
|
return {
|
||||||
|
mode: normalizeEnum(raw.mode, VIDEO_MODES, DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo.mode),
|
||||||
|
userThreshold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||||
const raw = config.bandwidthSavings || {};
|
const raw = config.bandwidthSavings || {};
|
||||||
return {
|
return {
|
||||||
@@ -33,11 +63,11 @@ function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
|||||||
MULTI_TAB_MODES,
|
MULTI_TAB_MODES,
|
||||||
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||||
),
|
),
|
||||||
nonTurnVideo: normalizeEnum(
|
pauseHiddenRoverVideo: normalizeBoolean(
|
||||||
raw.nonTurnVideo,
|
raw.pauseHiddenRoverVideo,
|
||||||
VIDEO_MODES,
|
DEFAULT_BANDWIDTH_SAVINGS.pauseHiddenRoverVideo,
|
||||||
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
|
|
||||||
),
|
),
|
||||||
|
nonTurnVideo: normalizeNonTurnVideo(raw.nonTurnVideo),
|
||||||
externalSpectatorVideo: normalizeEnum(
|
externalSpectatorVideo: normalizeEnum(
|
||||||
raw.externalSpectatorVideo,
|
raw.externalSpectatorVideo,
|
||||||
VIDEO_MODES,
|
VIDEO_MODES,
|
||||||
@@ -72,8 +102,16 @@ function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } =
|
|||||||
return !isVerified && !isAdmin;
|
return !isVerified && !isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldUseSnapshotsForNonTurnVideo() {
|
function shouldUseSnapshotsForNonTurnVideo({ controllableUserCount = 0 } = {}) {
|
||||||
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots';
|
const { nonTurnVideo } = getBandwidthSavingsPolicy();
|
||||||
|
if (nonTurnVideo.mode !== 'snapshots') return false;
|
||||||
|
/*
|
||||||
|
The threshold is evaluated centrally so MediaMTX auth, socket-issued video
|
||||||
|
tokens, PTZ authorization, and browser session state all agree. Using a
|
||||||
|
strict greater-than comparison makes the configured value read like the
|
||||||
|
maximum number of controllable users allowed before snapshots start.
|
||||||
|
*/
|
||||||
|
return Math.max(0, Number(controllableUserCount) || 0) > nonTurnVideo.userThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldUseSnapshotsForExternalSpectatorVideo() {
|
function shouldUseSnapshotsForExternalSpectatorVideo() {
|
||||||
|
|||||||
@@ -46,11 +46,13 @@ function buildFeatureFlags(config = loadConfig()) {
|
|||||||
const kinectConfig = config.kinect || {};
|
const kinectConfig = config.kinect || {};
|
||||||
const buttonBoxConfig = config.buttonBox || {};
|
const buttonBoxConfig = config.buttonBox || {};
|
||||||
const barcodeScannerConfig = config.barcodeScanner || {};
|
const barcodeScannerConfig = config.barcodeScanner || {};
|
||||||
|
const balanceBoardConfig = config.balanceBoard || {};
|
||||||
const barcodeGamesConfig = config.barcodeGames || {};
|
const barcodeGamesConfig = config.barcodeGames || {};
|
||||||
const socialsConfig = config.socials || {};
|
const socialsConfig = config.socials || {};
|
||||||
const interInstanceConfig = config.interInstance || {};
|
const interInstanceConfig = config.interInstance || {};
|
||||||
const ptzCameraConfig = config.ptzCamera || {};
|
const ptzCameraConfig = config.ptzCamera || {};
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
|
const fleetReportsConfig = config.fleetReports || {};
|
||||||
const homeAssistant = Boolean(
|
const homeAssistant = Boolean(
|
||||||
asBoolean(homeAssistantConfig.enabled) &&
|
asBoolean(homeAssistantConfig.enabled) &&
|
||||||
asTrimmedString(homeAssistantConfig.url) &&
|
asTrimmedString(homeAssistantConfig.url) &&
|
||||||
@@ -68,6 +70,10 @@ function buildFeatureFlags(config = loadConfig()) {
|
|||||||
kinect: asBoolean(kinectConfig.enabled),
|
kinect: asBoolean(kinectConfig.enabled),
|
||||||
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
buttonBox: asBoolean(buttonBoxConfig.enabled),
|
||||||
barcodeScanner,
|
barcodeScanner,
|
||||||
|
// The worker performs its own runtime availability reporting. Advertising
|
||||||
|
// the feature from the explicit config switch lets the UI show useful
|
||||||
|
// commissioning and hardware-error states even before a board is paired.
|
||||||
|
balanceBoard: asBoolean(balanceBoardConfig.enabled),
|
||||||
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
barcodeGames: Boolean(barcodeScanner && asBoolean(barcodeGamesConfig.enabled)),
|
||||||
lift: Boolean(
|
lift: Boolean(
|
||||||
homeAssistant &&
|
homeAssistant &&
|
||||||
@@ -95,6 +101,11 @@ function buildFeatureFlags(config = loadConfig()) {
|
|||||||
to run without the integration.
|
to run without the integration.
|
||||||
*/
|
*/
|
||||||
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
discord: Boolean(asBoolean(discordConfig.enabled) && asTrimmedString(discordConfig.token)),
|
||||||
|
// Fleet reports are deliberately controlled by one explicit server switch.
|
||||||
|
// Storage contents, Discord availability, or historical database files must
|
||||||
|
// never cause the reporting UI to appear on an installation that has not
|
||||||
|
// opted into the collector.
|
||||||
|
fleetReports: asBoolean(fleetReportsConfig.enabled),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Site Metadata Helper
|
||||||
|
// Purpose: Resolves the public name, description, and colors used before the web UI starts.
|
||||||
|
// Scope: Keeps document/PWA branding server-rendered and independent of Socket.IO session state.
|
||||||
|
const { loadConfig } = require('./configLoader');
|
||||||
|
|
||||||
|
const DEFAULT_SITE_METADATA = Object.freeze({
|
||||||
|
name: 'Multi Roomba Rover',
|
||||||
|
shortName: 'Multi Roomba Rover',
|
||||||
|
description: 'Drive and watch remote rovers from your browser.',
|
||||||
|
accentColor: '#38bdf8',
|
||||||
|
backgroundColor: '#020617',
|
||||||
|
publicUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const BACKGROUND_BLEND_AMOUNT = 0.15;
|
||||||
|
|
||||||
|
function asTrimmedString(value) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHexColor(value) {
|
||||||
|
const color = asTrimmedString(value).toLowerCase();
|
||||||
|
|
||||||
|
/*
|
||||||
|
Supporting both common CSS hex forms keeps the operator-facing setting
|
||||||
|
forgiving while still preventing arbitrary CSS from being injected into
|
||||||
|
generated HTML and SVG attributes.
|
||||||
|
*/
|
||||||
|
if (/^#[0-9a-f]{6}$/.test(color)) return color;
|
||||||
|
if (/^#[0-9a-f]{3}$/.test(color)) {
|
||||||
|
return `#${color.slice(1).split('').map((character) => character.repeat(2)).join('')}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blendHexColors(baseColor, accentColor, accentAmount) {
|
||||||
|
const base = baseColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||||
|
const accent = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||||
|
|
||||||
|
/*
|
||||||
|
The profile color is deliberately only a tint. A full-strength profile
|
||||||
|
color could produce a glaring PWA launch screen, while this blend preserves
|
||||||
|
the application's established dark appearance and still makes each server
|
||||||
|
visually recognizable.
|
||||||
|
*/
|
||||||
|
const channels = base.map((channel, index) =>
|
||||||
|
Math.round(channel * (1 - accentAmount) + accent[index] * accentAmount),
|
||||||
|
);
|
||||||
|
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicUrl(value) {
|
||||||
|
const candidate = asTrimmedString(value);
|
||||||
|
if (!candidate) return null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
URL() helpfully repairs strings such as `http:192.168.0.1`, but preserving
|
||||||
|
that typo in public metadata would conceal a configuration mistake. Require
|
||||||
|
the conventional absolute URL form so the published address is explicit.
|
||||||
|
*/
|
||||||
|
if (!/^https?:\/\//i.test(candidate)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Removing a trailing slash gives callers one stable base URL to combine
|
||||||
|
with paths. Invalid values are ignored instead of producing broken
|
||||||
|
canonical and social metadata on every page.
|
||||||
|
*/
|
||||||
|
return url.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadableAccentText(accentColor) {
|
||||||
|
const channels = accentColor.slice(1).match(/.{2}/g).map((channel) => Number.parseInt(channel, 16));
|
||||||
|
const luminance = (channels[0] * 299 + channels[1] * 587 + channels[2] * 114) / 1000;
|
||||||
|
|
||||||
|
// A simple luminance split keeps the generated preview badge legible for both dark and light profile colors.
|
||||||
|
return luminance > 150 ? '#020617' : '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSiteMetadata(config = loadConfig()) {
|
||||||
|
const interInstance = config?.interInstance;
|
||||||
|
const profile = interInstance?.profile;
|
||||||
|
const profileName = asTrimmedString(profile?.name);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A partially filled profile must not unexpectedly rename the site. The
|
||||||
|
inter-instance feature must be explicitly enabled and have a usable name
|
||||||
|
before any profile branding is applied; otherwise every value comes from
|
||||||
|
the coherent default set above.
|
||||||
|
*/
|
||||||
|
if (interInstance?.enabled !== true || !profileName) {
|
||||||
|
return { ...DEFAULT_SITE_METADATA, accentTextColor: getReadableAccentText(DEFAULT_SITE_METADATA.accentColor) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentColor = normalizeHexColor(profile.color) || DEFAULT_SITE_METADATA.accentColor;
|
||||||
|
return {
|
||||||
|
name: profileName,
|
||||||
|
shortName: profileName,
|
||||||
|
description: asTrimmedString(profile.description) || DEFAULT_SITE_METADATA.description,
|
||||||
|
accentColor,
|
||||||
|
backgroundColor: blendHexColors(
|
||||||
|
DEFAULT_SITE_METADATA.backgroundColor,
|
||||||
|
accentColor,
|
||||||
|
BACKGROUND_BLEND_AMOUNT,
|
||||||
|
),
|
||||||
|
accentTextColor: getReadableAccentText(accentColor),
|
||||||
|
publicUrl: normalizePublicUrl(profile.publicUrl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_SITE_METADATA,
|
||||||
|
resolveSiteMetadata,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Reward Definition: Green Mode
|
||||||
|
// Purpose: Enables the server-wide green theme and room effect for twenty minutes.
|
||||||
|
// Scope: Owns button-box timing/recovery while delegating the actual mode to greenModeService.
|
||||||
|
const DURATION_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
let activeTimer = null;
|
||||||
|
let unsubscribeGreenMode = null;
|
||||||
|
|
||||||
|
function clearRuntimeWatchers() {
|
||||||
|
if (activeTimer) {
|
||||||
|
clearTimeout(activeTimer);
|
||||||
|
activeTimer = null;
|
||||||
|
}
|
||||||
|
if (unsubscribeGreenMode) {
|
||||||
|
unsubscribeGreenMode();
|
||||||
|
unsubscribeGreenMode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopGreenMode(ctx) {
|
||||||
|
clearRuntimeWatchers();
|
||||||
|
await ctx.setGreenMode(false, { source: 'buttonbox:greenModeExpired' });
|
||||||
|
ctx.clearEffect('greenMode');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGreenMode(ctx, effect = {}) {
|
||||||
|
clearRuntimeWatchers();
|
||||||
|
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
|
||||||
|
const remaining = Math.max(0, endsAt - Date.now());
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
await stopGreenMode(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.setGreenMode(true, { source: 'buttonbox:greenMode' });
|
||||||
|
ctx.saveEffect('greenMode', { endsAt });
|
||||||
|
|
||||||
|
/*
|
||||||
|
Access-mode changes disable green mode through greenModeService. Watching
|
||||||
|
that shared state transition lets the reward discard its persisted effect
|
||||||
|
immediately, so a restart cannot accidentally revive a reward that was
|
||||||
|
intentionally ended early.
|
||||||
|
*/
|
||||||
|
unsubscribeGreenMode = ctx.onGreenModeChange((enabled) => {
|
||||||
|
if (enabled) return;
|
||||||
|
clearRuntimeWatchers();
|
||||||
|
ctx.clearEffect('greenMode');
|
||||||
|
});
|
||||||
|
|
||||||
|
activeTimer = setTimeout(() => {
|
||||||
|
stopGreenMode(ctx).catch((err) => {
|
||||||
|
ctx.logger.warn('green mode reward stop failed', { error: err.message });
|
||||||
|
});
|
||||||
|
}, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'greenMode',
|
||||||
|
name: 'Green mode',
|
||||||
|
description: 'Makes the room and server green for 20 minutes.',
|
||||||
|
goal: 5,
|
||||||
|
async run(ctx) {
|
||||||
|
await startGreenMode(ctx, { endsAt: Date.now() + DURATION_MS });
|
||||||
|
},
|
||||||
|
async recover(ctx, effect) {
|
||||||
|
// Recovery must never manufacture a fresh twenty-minute window from a
|
||||||
|
// missing or corrupt persisted deadline. Treat it as expired and clean up.
|
||||||
|
if (!Number.isFinite(Number(effect?.endsAt))) {
|
||||||
|
await stopGreenMode(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startGreenMode(ctx, effect);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Green Mode Reward Tests
|
||||||
|
// Purpose: Pins the five-press metadata and persisted timed-effect lifecycle.
|
||||||
|
// Scope: Uses a small context double; greenModeService behavior is tested through its public contract.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const reward = require('./greenMode');
|
||||||
|
|
||||||
|
function createContext() {
|
||||||
|
const calls = [];
|
||||||
|
let changeListener = null;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
logger: { warn: () => {} },
|
||||||
|
setGreenMode: async (enabled, options) => {
|
||||||
|
calls.push({ type: 'set', enabled, source: options?.source });
|
||||||
|
return enabled;
|
||||||
|
},
|
||||||
|
saveEffect: (id, payload) => calls.push({ type: 'save', id, payload }),
|
||||||
|
clearEffect: (id) => calls.push({ type: 'clear', id }),
|
||||||
|
onGreenModeChange: (listener) => {
|
||||||
|
changeListener = listener;
|
||||||
|
return () => {
|
||||||
|
changeListener = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
emitGreenModeChange: (enabled) => changeListener?.(enabled),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('green mode reward requires five presses and starts a persisted effect', async () => {
|
||||||
|
const ctx = createContext();
|
||||||
|
assert.equal(reward.goal, 5);
|
||||||
|
|
||||||
|
await reward.run(ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(ctx.calls[0], { type: 'set', enabled: true, source: 'buttonbox:greenMode' });
|
||||||
|
const saved = ctx.calls.find((call) => call.type === 'save');
|
||||||
|
assert.equal(saved?.id, 'greenMode');
|
||||||
|
assert.ok(saved?.payload?.endsAt > Date.now());
|
||||||
|
|
||||||
|
// Simulate an access-mode shutdown so the test also clears the reward's
|
||||||
|
// twenty-minute timer instead of leaving background work in the test process.
|
||||||
|
ctx.emitGreenModeChange(false);
|
||||||
|
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid recovery state is cleared instead of starting a new duration', async () => {
|
||||||
|
const ctx = createContext();
|
||||||
|
await reward.recover(ctx, {});
|
||||||
|
|
||||||
|
assert.deepEqual(ctx.calls[0], {
|
||||||
|
type: 'set',
|
||||||
|
enabled: false,
|
||||||
|
source: 'buttonbox:greenModeExpired',
|
||||||
|
});
|
||||||
|
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ const discordPingEveryone = require('./definitions/discordPingEveryone');
|
|||||||
const modeJam = require('./definitions/modeJam');
|
const modeJam = require('./definitions/modeJam');
|
||||||
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
||||||
const chatSpam = require('./definitions/chatSpam');
|
const chatSpam = require('./definitions/chatSpam');
|
||||||
|
const greenMode = require('./definitions/greenMode');
|
||||||
|
|
||||||
const orderedRewards = [
|
const orderedRewards = [
|
||||||
dockPanic,
|
dockPanic,
|
||||||
@@ -22,6 +23,7 @@ const orderedRewards = [
|
|||||||
modeJam,
|
modeJam,
|
||||||
assignmentRoulette,
|
assignmentRoulette,
|
||||||
chatSpam,
|
chatSpam,
|
||||||
|
greenMode,
|
||||||
];
|
];
|
||||||
|
|
||||||
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
|
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
|
|||||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { compareRoversForAssignment } = require('./roverRanking');
|
||||||
|
|
||||||
const socketRefs = new Map(); // socketId -> socket
|
const socketRefs = new Map(); // socketId -> socket
|
||||||
const assignments = new Map(); // socketId -> roverId
|
const assignments = new Map(); // socketId -> roverId
|
||||||
@@ -96,8 +97,20 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action }) => {
|
||||||
if (action === 'removed' || action === 'upsert') {
|
if (action === 'removed') {
|
||||||
|
/*
|
||||||
|
The physical rover record is the authority for current driver ownership.
|
||||||
|
Once it disappears, every assignment that names it must be released and
|
||||||
|
run through ordinary placement again. Leaving those map entries intact
|
||||||
|
lets the same id become visible after reconnect without recreating its
|
||||||
|
driver membership, which is the exact stale-UI/video-auth split this
|
||||||
|
lifecycle boundary must prevent.
|
||||||
|
*/
|
||||||
|
reassignFromRover(roverId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'upsert') {
|
||||||
reassignWaiting();
|
reassignWaiting();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -241,35 +254,17 @@ function pickRover(socket, options = {}) {
|
|||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const dockedRank = (rover) => {
|
/*
|
||||||
if (!rover) return 0;
|
Eligibility is resolved above, while this shared comparator owns only the
|
||||||
if (rover.docked === true) return -1;
|
requested placement order: empty, undocked when empty, driver count, then
|
||||||
if (rover.docked === false) return 1;
|
battery percentage.
|
||||||
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
|
Keeping those concerns separate prevents a ranking change from weakening
|
||||||
const docked = sensors?.chargingSources?.homeBase;
|
lock, private-rover, role, or mode access checks.
|
||||||
if (docked === true) return -1;
|
*/
|
||||||
if (docked === false) return 1;
|
candidates.sort(compareRoversForAssignment);
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
|
|
||||||
const compare = (a, b) => {
|
|
||||||
const aEmpty = idleRank(a);
|
|
||||||
const bEmpty = idleRank(b);
|
|
||||||
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
|
|
||||||
const aDockRank = dockedRank(a);
|
|
||||||
const bDockRank = dockedRank(b);
|
|
||||||
if (aEmpty === 1 && aDockRank !== bDockRank) {
|
|
||||||
return bDockRank - aDockRank;
|
|
||||||
}
|
|
||||||
if (a.drivers.size !== b.drivers.size) {
|
|
||||||
return a.drivers.size - b.drivers.size;
|
|
||||||
}
|
|
||||||
return bDockRank - aDockRank;
|
|
||||||
};
|
|
||||||
candidates.sort(compare);
|
|
||||||
const best = candidates[0];
|
const best = candidates[0];
|
||||||
if (!best) return null;
|
if (!best) return null;
|
||||||
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
|
const bestTier = candidates.filter((entry) => compareRoversForAssignment(entry, best) === 0);
|
||||||
if (!bestTier.length) return best;
|
if (!bestTier.length) return best;
|
||||||
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
|
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Rover assignment ranking
|
||||||
|
// Purpose: Ranks otherwise eligible rovers using the fleet's assignment priorities.
|
||||||
|
// Scope: Contains only deterministic comparison logic; access checks and the final random tie-break remain in assignmentService.
|
||||||
|
|
||||||
|
function readDockedState(rover) {
|
||||||
|
/*
|
||||||
|
The rover record normally exposes the server's canonical docked state. The
|
||||||
|
sensor fallback covers the short interval where telemetry has arrived but
|
||||||
|
the derived top-level field has not yet been synchronized. Unknown docking
|
||||||
|
state deliberately remains unknown instead of being treated as undocked.
|
||||||
|
*/
|
||||||
|
if (rover?.docked === true || rover?.docked === false) return rover.docked;
|
||||||
|
const sensors = rover?.lastSensor?.decoded || rover?.lastSensor?.sensors || null;
|
||||||
|
const homeBase = sensors?.chargingSources?.homeBase;
|
||||||
|
return homeBase === true || homeBase === false ? homeBase : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverCount(rover) {
|
||||||
|
/*
|
||||||
|
Production rover records use a Set. Returning a safe high-level count here
|
||||||
|
keeps ranking predictable for partially initialized records and makes the
|
||||||
|
comparator straightforward to exercise with small test fixtures.
|
||||||
|
*/
|
||||||
|
return Number.isFinite(rover?.drivers?.size) ? rover.drivers.size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryPercentage(rover) {
|
||||||
|
/*
|
||||||
|
percentDisplay is the canonical server-normalized percentage used by the
|
||||||
|
rest of the application. Missing or invalid telemetry receives no invented
|
||||||
|
percentage; the comparator places unknown batteries after every known one.
|
||||||
|
*/
|
||||||
|
const percentage = rover?.batteryState?.percentDisplay;
|
||||||
|
return Number.isFinite(percentage) ? percentage : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRoversForAssignment(left, right) {
|
||||||
|
/*
|
||||||
|
Spread drivers across the fleet before adding another person to an existing
|
||||||
|
rover queue. This comparison is deliberately independent of battery: a
|
||||||
|
small battery-percentage difference should never concentrate users on one
|
||||||
|
rover while another eligible rover has nobody assigned.
|
||||||
|
*/
|
||||||
|
const leftDrivers = driverCount(left);
|
||||||
|
const rightDrivers = driverCount(right);
|
||||||
|
const leftEmpty = leftDrivers === 0;
|
||||||
|
const rightEmpty = rightDrivers === 0;
|
||||||
|
if (leftEmpty !== rightEmpty) return leftEmpty ? -1 : 1;
|
||||||
|
|
||||||
|
/*
|
||||||
|
When both choices are empty, prefer the rover that is already away from its
|
||||||
|
dock. Docking state does not separate occupied rovers because queue balance
|
||||||
|
is more useful there, and an existing driver may already be handling the
|
||||||
|
rover's physical state. Unknown docking telemetry receives no undocked
|
||||||
|
preference rather than being guessed as ready.
|
||||||
|
*/
|
||||||
|
if (leftEmpty && rightEmpty) {
|
||||||
|
const leftUndocked = readDockedState(left) === false;
|
||||||
|
const rightUndocked = readDockedState(right) === false;
|
||||||
|
if (leftUndocked !== rightUndocked) return leftUndocked ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
For occupied rovers, queue length is the primary balancing signal. This is
|
||||||
|
intentionally evaluated before battery so a one-percent battery advantage
|
||||||
|
cannot cause every later user to pile onto the same rover.
|
||||||
|
*/
|
||||||
|
if (leftDrivers !== rightDrivers) return leftDrivers - rightDrivers;
|
||||||
|
|
||||||
|
const leftBattery = batteryPercentage(left);
|
||||||
|
const rightBattery = batteryPercentage(right);
|
||||||
|
const leftHasBattery = leftBattery != null;
|
||||||
|
const rightHasBattery = rightBattery != null;
|
||||||
|
if (leftHasBattery !== rightHasBattery) return leftHasBattery ? -1 : 1;
|
||||||
|
if (leftHasBattery && leftBattery !== rightBattery) return rightBattery - leftBattery;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Returning zero is intentional. assignmentService randomly selects from the
|
||||||
|
complete best tier so stable Map insertion order cannot permanently favor a
|
||||||
|
rover whose emptiness, docking state, load, and battery are all equivalent.
|
||||||
|
*/
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
compareRoversForAssignment,
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Rover assignment ranking tests
|
||||||
|
// Purpose: Locks the operator-defined rover priority order against accidental comparator regressions.
|
||||||
|
// Scope: Tests pure ranking only; assignment side effects and access policy remain owned by their existing services.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { compareRoversForAssignment } = require('./roverRanking');
|
||||||
|
|
||||||
|
function rover({ id, docked, battery, drivers = 0 }) {
|
||||||
|
/*
|
||||||
|
Set size matches the production rover contract without introducing socket or
|
||||||
|
rover-manager dependencies into these focused ordering tests.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
docked,
|
||||||
|
batteryState: battery == null ? null : { percentDisplay: battery },
|
||||||
|
drivers: new Set(Array.from({ length: drivers }, (_, index) => `${id}-driver-${index}`)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankedIds(entries) {
|
||||||
|
return entries.sort(compareRoversForAssignment).map((entry) => entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an empty rover outranks an occupied rover regardless of battery or docking state', () => {
|
||||||
|
const result = rankedIds([
|
||||||
|
rover({ id: 'occupied-high', docked: false, battery: 100, drivers: 1 }),
|
||||||
|
rover({ id: 'docked-empty', docked: true, battery: 20 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['docked-empty', 'occupied-high']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an undocked rover is preferred when both rovers are empty', () => {
|
||||||
|
const result = rankedIds([
|
||||||
|
rover({ id: 'docked-high', docked: true, battery: 100 }),
|
||||||
|
rover({ id: 'undocked-low', docked: false, battery: 20 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['undocked-low', 'docked-high']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lowest driver count ranks occupied rovers before battery percentage', () => {
|
||||||
|
const result = rankedIds([
|
||||||
|
rover({ id: 'busy-high', docked: false, battery: 100, drivers: 4 }),
|
||||||
|
rover({ id: 'quieter-low', docked: false, battery: 20, drivers: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['quieter-low', 'busy-high']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('battery percentage ranks rovers after availability and load are equal', () => {
|
||||||
|
const result = rankedIds([
|
||||||
|
rover({ id: 'low', docked: false, battery: 35, drivers: 1 }),
|
||||||
|
rover({ id: 'high', docked: false, battery: 90, drivers: 1 }),
|
||||||
|
rover({ id: 'middle', docked: false, battery: 60, drivers: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['high', 'middle', 'low']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('known battery percentage outranks missing battery telemetry', () => {
|
||||||
|
const result = rankedIds([
|
||||||
|
rover({ id: 'unknown', docked: true, battery: null }),
|
||||||
|
rover({ id: 'known', docked: true, battery: 5 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, ['known', 'unknown']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exactly equivalent rovers remain tied for random selection by assignmentService', () => {
|
||||||
|
const left = rover({ id: 'left', docked: false, battery: 80, drivers: 1 });
|
||||||
|
const right = rover({ id: 'right', docked: false, battery: 80, drivers: 1 });
|
||||||
|
|
||||||
|
assert.equal(compareRoversForAssignment(left, right), 0);
|
||||||
|
assert.equal(compareRoversForAssignment(right, left), 0);
|
||||||
|
});
|
||||||
@@ -23,8 +23,34 @@ function registerAudioForwardHooks(deps) {
|
|||||||
buildWhipUrl,
|
buildWhipUrl,
|
||||||
videoSessions,
|
videoSessions,
|
||||||
startSilenceWriter,
|
startSilenceWriter,
|
||||||
|
isMuted,
|
||||||
|
verificationEvents,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
verificationEvents.on('change', ({ socketId } = {}) => {
|
||||||
|
if (!socketId) return;
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (!socket || !isMuted(socket)) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Permission checks stop new muted audio, but an upload or microphone can
|
||||||
|
already be live when moderation changes. Stop only streams owned by this
|
||||||
|
socket so muting does not disturb another driver's audio or unrelated
|
||||||
|
server-generated sounds.
|
||||||
|
*/
|
||||||
|
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||||
|
if (ownerSocketId === socketId) {
|
||||||
|
stopWhipForRover(roverId, 'owner_muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workers.forEach((worker, roverId) => {
|
||||||
|
if (worker?.contentKind === 'upload' && worker.activeOwnerSocketId === socketId) {
|
||||||
|
logger.info('Stopping uploaded audio because its owner was muted', { roverId, socketId });
|
||||||
|
startSilenceWriter(roverId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
if (action === 'removed') {
|
if (action === 'removed') {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const logger = require('../../globals/logger').child('audioForwardService');
|
|||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const turnService = require('../turnService');
|
const turnService = require('../turnService');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isMuted, isVerified, verificationEvents } = require('../verificationService');
|
||||||
const videoSessions = require('../videoSessions');
|
const videoSessions = require('../videoSessions');
|
||||||
const { createAudioForwardPolicy } = require('./policy');
|
const { createAudioForwardPolicy } = require('./policy');
|
||||||
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||||
@@ -62,6 +62,7 @@ function getAudioForwardState() {
|
|||||||
|
|
||||||
const audioForwardPolicy = createAudioForwardPolicy({
|
const audioForwardPolicy = createAudioForwardPolicy({
|
||||||
isVerified,
|
isVerified,
|
||||||
|
isMuted,
|
||||||
roverManager,
|
roverManager,
|
||||||
turnService,
|
turnService,
|
||||||
streamSuffix,
|
streamSuffix,
|
||||||
@@ -141,6 +142,8 @@ registerAudioForwardHooks({
|
|||||||
buildWhipUrl,
|
buildWhipUrl,
|
||||||
videoSessions,
|
videoSessions,
|
||||||
startSilenceWriter,
|
startSilenceWriter,
|
||||||
|
isMuted,
|
||||||
|
verificationEvents,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerChargeCompleteSound({
|
registerChargeCompleteSound({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
function createAudioForwardPolicy(deps) {
|
function createAudioForwardPolicy(deps) {
|
||||||
const {
|
const {
|
||||||
isVerified,
|
isVerified,
|
||||||
|
isMuted,
|
||||||
roverManager,
|
roverManager,
|
||||||
turnService,
|
turnService,
|
||||||
streamSuffix,
|
streamSuffix,
|
||||||
@@ -18,6 +19,9 @@ function createAudioForwardPolicy(deps) {
|
|||||||
|
|
||||||
function ensureAudioForwardPermission(socket, roverId) {
|
function ensureAudioForwardPermission(socket, roverId) {
|
||||||
ensureVipVerified(socket);
|
ensureVipVerified(socket);
|
||||||
|
if (isMuted(socket)) {
|
||||||
|
throw new Error('Muted');
|
||||||
|
}
|
||||||
if (!roverManager.isDriver(roverId, socket)) {
|
if (!roverManager.isDriver(roverId, socket)) {
|
||||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||||
}
|
}
|
||||||
@@ -26,25 +30,13 @@ function createAudioForwardPolicy(deps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function forcePublishStreamMode(rawUrl) {
|
|
||||||
const value = String(rawUrl || '').trim();
|
|
||||||
if (!value) return '';
|
|
||||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
|
||||||
if (/,m=publish\b/.test(value)) return value;
|
|
||||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
|
||||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveForwardUrl(roverId) {
|
function resolveForwardUrl(roverId) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
/*
|
||||||
// Rovers listen to the playback stream with a request/read URL. The VIP
|
The server publishes to its own MediaMTX child, so loopback is the stable and correct
|
||||||
// upload path needs to publish into that same stream, so the configured
|
route regardless of which hostname a rover uses to reach this machine. RTSP uses the
|
||||||
// nested playback URL is converted to publish mode below.
|
same path for publish and read; ANNOUNCE/RECORD and DESCRIBE/PLAY distinguish direction.
|
||||||
const configured = record?.meta?.media?.audioPlayback?.forwardUrl;
|
*/
|
||||||
if (configured) return forcePublishStreamMode(configured);
|
return `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId + streamSuffix)}`;
|
||||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
|
||||||
roverId + streamSuffix,
|
|
||||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveForwardPathId(roverId) {
|
function resolveForwardPathId(roverId) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Audio Forward Policy Tests
|
||||||
|
// Purpose: Verifies that mute blocks user-owned forwarding without changing ordinary driver authorization.
|
||||||
|
// Scope: Exercises the pure permission policy with small injected role, rover, and turn doubles.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createAudioForwardPolicy } = require('./policy');
|
||||||
|
|
||||||
|
function createPolicy({ verified = true, muted = false, driver = true, canDrive = true } = {}) {
|
||||||
|
return createAudioForwardPolicy({
|
||||||
|
isVerified: () => verified,
|
||||||
|
isMuted: () => muted,
|
||||||
|
roverManager: { isDriver: () => driver },
|
||||||
|
turnService: { canDrive: () => canDrive },
|
||||||
|
streamSuffix: '-fwd',
|
||||||
|
mediaConfig: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects audio forwarding for a muted verified driver', () => {
|
||||||
|
const policy = createPolicy({ muted: true });
|
||||||
|
assert.throws(() => policy.ensureAudioForwardPermission({}, 'rover'), /Muted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves normal audio forwarding for an unmuted verified driver', () => {
|
||||||
|
const policy = createPolicy();
|
||||||
|
assert.doesNotThrow(() => policy.ensureAudioForwardPermission({}, 'rover'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishes forwarded audio to the local MediaMTX RTSP path', () => {
|
||||||
|
const policy = createPolicy();
|
||||||
|
assert.equal(policy.resolveForwardUrl('rover one'), 'rtsp://127.0.0.1:8554/rover%20one-fwd');
|
||||||
|
});
|
||||||
@@ -86,7 +86,7 @@ function createAudioForwardWorkerEngine(deps) {
|
|||||||
exited = true;
|
exited = true;
|
||||||
};
|
};
|
||||||
// ChildProcess.killed only means Node successfully sent a signal, not that
|
// ChildProcess.killed only means Node successfully sent a signal, not that
|
||||||
// ffmpeg actually exited. Track the real exit event so FIFO/SRT hangs still
|
// ffmpeg actually exited. Track the real exit event so FIFO/publisher hangs still
|
||||||
// get escalated to SIGKILL instead of making systemd wait for its timeout.
|
// get escalated to SIGKILL instead of making systemd wait for its timeout.
|
||||||
proc.once('exit', markExited);
|
proc.once('exit', markExited);
|
||||||
try {
|
try {
|
||||||
@@ -145,7 +145,13 @@ function createAudioForwardWorkerEngine(deps) {
|
|||||||
'-muxpreload',
|
'-muxpreload',
|
||||||
'0',
|
'0',
|
||||||
'-f',
|
'-f',
|
||||||
'mpegts',
|
'rtsp',
|
||||||
|
/*
|
||||||
|
The MediaMTX listener accepts RTSP over TCP only. Pinning it here makes the server's
|
||||||
|
own publisher follow the same reliable transport contract as every rover publisher.
|
||||||
|
*/
|
||||||
|
'-rtsp_transport',
|
||||||
|
'tcp',
|
||||||
outputUrl,
|
outputUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Audio Adjustment Math
|
||||||
|
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
|
||||||
|
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
|
||||||
|
const ADJUSTMENT_FIELDS = [
|
||||||
|
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
|
||||||
|
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
|
||||||
|
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
|
||||||
|
];
|
||||||
|
const MIN_GAIN = 0;
|
||||||
|
const MAX_GAIN = 4;
|
||||||
|
const MIN_ADJUSTMENT_PERCENT = -100;
|
||||||
|
const MAX_ADJUSTMENT_PERCENT = 100;
|
||||||
|
|
||||||
|
function clampGain(value, fallback = 1) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampMaximumAdjustmentPercent(value, fallback = 50) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) return fallback;
|
||||||
|
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampAdjustmentPercent(value, maximum = 0) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) return 0;
|
||||||
|
const limit = clampMaximumAdjustmentPercent(maximum, 0);
|
||||||
|
return Math.round(Math.max(-limit, Math.min(limit, number)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAdjustments(raw = {}, maximum = 0) {
|
||||||
|
const normalized = {};
|
||||||
|
ADJUSTMENT_FIELDS.forEach(({ percentKey }) => {
|
||||||
|
normalized[percentKey] = clampAdjustmentPercent(raw?.[percentKey], maximum);
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAdjustments(baseLevels = {}, adjustments = {}) {
|
||||||
|
const effective = {};
|
||||||
|
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
|
||||||
|
const base = clampGain(baseLevels?.[gainKey], 0);
|
||||||
|
const percentage = Math.max(MIN_ADJUSTMENT_PERCENT, Math.min(MAX_ADJUSTMENT_PERCENT, Number(adjustments?.[percentKey]) || 0));
|
||||||
|
effective[gainKey] = clampGain(base * (1 + percentage / 100), 0);
|
||||||
|
});
|
||||||
|
return effective;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ADJUSTMENT_FIELDS,
|
||||||
|
MIN_GAIN,
|
||||||
|
MAX_GAIN,
|
||||||
|
MIN_ADJUSTMENT_PERCENT,
|
||||||
|
MAX_ADJUSTMENT_PERCENT,
|
||||||
|
clampGain,
|
||||||
|
clampMaximumAdjustmentPercent,
|
||||||
|
clampAdjustmentPercent,
|
||||||
|
normalizeAdjustments,
|
||||||
|
applyAdjustments,
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Audio Adjustment Math Tests
|
||||||
|
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
|
||||||
|
// Scope: Covers only the pure rules used by audioLevelsService.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
|
||||||
|
|
||||||
|
test('the configured range is a whole percentage from zero through one hundred', () => {
|
||||||
|
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
|
||||||
|
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
|
||||||
|
assert.equal(clampMaximumAdjustmentPercent(500), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each browser percentage is clamped equally in both directions', () => {
|
||||||
|
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
|
||||||
|
hornPercent: -40,
|
||||||
|
ttsPercent: 10,
|
||||||
|
forwardPercent: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signed percentages adjust each server base gain', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
applyAdjustments(
|
||||||
|
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
|
||||||
|
{ hornPercent: -25, ttsPercent: 25, forwardPercent: 40 },
|
||||||
|
),
|
||||||
|
{ hornGain: 0.75, ttsGain: 2.5, forwardGain: 0.7 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('effective gains remain inside the rover hard bounds', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
|
||||||
|
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,35 +7,49 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { isAdmin } = require('../roleService');
|
const { isAdmin, roleEvents } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
|
const {
|
||||||
|
ADJUSTMENT_FIELDS,
|
||||||
|
clampGain,
|
||||||
|
clampMaximumAdjustmentPercent,
|
||||||
|
normalizeAdjustments,
|
||||||
|
applyAdjustments,
|
||||||
|
} = require('./gainMath');
|
||||||
|
|
||||||
const audioLevelsEvents = new EventEmitter();
|
const audioLevelsEvents = new EventEmitter();
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const configuredDefaults = config.audioLevels || {};
|
const configuredDefaults = config.audioLevels || {};
|
||||||
|
const PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
|
||||||
|
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||||
|
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||||
|
configuredDefaults.maxPersonalAdjustmentPercent,
|
||||||
|
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampGain(value, fallback = 1) {
|
|
||||||
const num = Number(value);
|
|
||||||
if (!Number.isFinite(num)) return fallback;
|
|
||||||
return Math.max(0, Math.min(4, num));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStore(raw = {}) {
|
function normalizeStore(raw = {}) {
|
||||||
return {
|
return {
|
||||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||||
|
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||||
|
raw.maxPersonalAdjustmentPercent,
|
||||||
|
DEFAULTS.maxPersonalAdjustmentPercent,
|
||||||
|
),
|
||||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||||
|
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
|
||||||
|
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +60,11 @@ function loadState() {
|
|||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
state = normalizeStore(raw);
|
state = normalizeStore(raw);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(raw, 'userGainCaps')) {
|
||||||
|
// Rewrite once so the retired VIP-cap object does not linger beside the
|
||||||
|
// new percentage range and confuse future operator inspection.
|
||||||
|
persistState(state);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code !== 'ENOENT') {
|
if (err.code !== 'ENOENT') {
|
||||||
logger.warn('Failed to load audio levels store', err.message);
|
logger.warn('Failed to load audio levels store', err.message);
|
||||||
@@ -71,18 +90,79 @@ function getAudioLevels() {
|
|||||||
hornGain: current.hornGain,
|
hornGain: current.hornGain,
|
||||||
ttsGain: current.ttsGain,
|
ttsGain: current.ttsGain,
|
||||||
forwardGain: current.forwardGain,
|
forwardGain: current.forwardGain,
|
||||||
|
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
|
||||||
updatedAt: current.updatedAt,
|
updatedAt: current.updatedAt,
|
||||||
updatedBy: current.updatedBy,
|
updatedBy: current.updatedBy,
|
||||||
|
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
|
||||||
|
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitChange(reason = 'update') {
|
function emitChange(reason = 'update', extra = {}) {
|
||||||
audioLevelsEvents.emit('change', {
|
audioLevelsEvents.emit('change', {
|
||||||
reason,
|
reason,
|
||||||
levels: getAudioLevels(),
|
levels: getAudioLevels(),
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdminLimits() {
|
||||||
|
const current = loadState();
|
||||||
|
return {
|
||||||
|
hornGain: current.hornGain,
|
||||||
|
ttsGain: current.ttsGain,
|
||||||
|
forwardGain: current.forwardGain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUsePersonalAdjustments(socket) {
|
||||||
|
if (isAdmin(socket)) return true;
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdjustmentsForSocket(socket) {
|
||||||
|
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
|
||||||
|
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveLevelsForSocket(socket) {
|
||||||
|
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The rover applies gain as three ALSA master controls, so only one set of gains
|
||||||
|
can be live per rover at a time. That is not a limitation in practice: horn,
|
||||||
|
TTS, and mic forwarding are all restricted to the socket currently holding
|
||||||
|
audio control, so pushing that socket's resolved gains gives genuinely
|
||||||
|
per-user volume. When nobody owns audio the global admin gains apply.
|
||||||
|
*/
|
||||||
|
function resolveAudioOwnerSocket(roverId) {
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record) return null;
|
||||||
|
const driverIds = Array.from(record.drivers || []);
|
||||||
|
if (!driverIds.length) return null;
|
||||||
|
|
||||||
|
// Required lazily: turnService reaches back into roverManager during startup.
|
||||||
|
let activeSocketId = null;
|
||||||
|
try {
|
||||||
|
activeSocketId = require('../turnService').getActiveDrivers()[roverId] || null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to resolve active driver for audio levels', roverId, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenId = activeSocketId && driverIds.includes(activeSocketId)
|
||||||
|
? activeSocketId
|
||||||
|
: (driverIds.length === 1 ? driverIds[0] : null);
|
||||||
|
if (!chosenId) return null;
|
||||||
|
return io.sockets.sockets.get(chosenId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLevelsForRover(roverId) {
|
||||||
|
const owner = resolveAudioOwnerSocket(roverId);
|
||||||
|
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
|
||||||
|
}
|
||||||
|
|
||||||
function pushLevelsToRover(roverId) {
|
function pushLevelsToRover(roverId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
@@ -90,7 +170,7 @@ function pushLevelsToRover(roverId) {
|
|||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'audioLevels',
|
type: 'audioLevels',
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: resolveLevelsForRover(roverId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||||
@@ -105,6 +185,11 @@ function pushLevelsToAllRovers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pushLevelsForSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
roverManager.getRoversForSocket(socket.id).forEach((roverId) => pushLevelsToRover(roverId));
|
||||||
|
}
|
||||||
|
|
||||||
function setAudioLevels(input = {}, actor = null) {
|
function setAudioLevels(input = {}, actor = null) {
|
||||||
const current = loadState();
|
const current = loadState();
|
||||||
const next = {
|
const next = {
|
||||||
@@ -121,12 +206,94 @@ function setAudioLevels(input = {}, actor = null) {
|
|||||||
return getAudioLevels();
|
return getAudioLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMaxPersonalAdjustmentPercent(value, actor = null) {
|
||||||
|
const current = loadState();
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
|
||||||
|
adjustmentRangeUpdatedAt: Date.now(),
|
||||||
|
adjustmentRangeUpdatedBy: actor,
|
||||||
|
};
|
||||||
|
persistState(next);
|
||||||
|
/*
|
||||||
|
A narrower range must take effect immediately for current drivers rather
|
||||||
|
than leaving an out-of-range multiplier active until their next turn.
|
||||||
|
*/
|
||||||
|
pushLevelsToAllRovers();
|
||||||
|
emitChange('personal_adjustment_range_set');
|
||||||
|
return loadState().maxPersonalAdjustmentPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSocketAdjustments(socket, input = {}) {
|
||||||
|
socket.data = socket.data || {};
|
||||||
|
// Store only server-normalized percentages on the transport. The cookie is a
|
||||||
|
// browser preference, while permission and range enforcement remain here.
|
||||||
|
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
|
||||||
|
pushLevelsForSocket(socket);
|
||||||
|
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
|
||||||
|
return getAudioAdjustmentStateForSocket(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The client receives the percentages the server accepted, the permitted range,
|
||||||
|
and the resulting multipliers. This keeps the UI honest even when a cookie was
|
||||||
|
edited or an administrator changed permission while the browser was online.
|
||||||
|
*/
|
||||||
|
function getAudioAdjustmentStateForSocket(socket) {
|
||||||
|
const allowed = canUsePersonalAdjustments(socket);
|
||||||
|
const maximum = loadState().maxPersonalAdjustmentPercent;
|
||||||
|
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
allowed,
|
||||||
|
maxAdjustmentPercent: maximum,
|
||||||
|
effective: applyAdjustments(getAdminLimits(), values),
|
||||||
|
baseLevels: getAdminLimits(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
if (action === 'upsert' && roverId) {
|
if (action === 'upsert' && roverId) {
|
||||||
pushLevelsToRover(roverId);
|
pushLevelsToRover(roverId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Whoever owns a rover's audio determines which gains are live, so the rover has
|
||||||
|
to be re-pushed whenever that ownership moves: joining or leaving a rover, and
|
||||||
|
every turn rotation.
|
||||||
|
*/
|
||||||
|
roverManager.managerEvents.on('driver', ({ roverId } = {}) => {
|
||||||
|
if (roverId) pushLevelsToRover(roverId);
|
||||||
|
});
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
require('../turnService').turnEvents.on('queue', ({ roverId } = {}) => {
|
||||||
|
if (roverId) pushLevelsToRover(roverId);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to subscribe to turn changes for audio levels', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
identityEvents.on('change', ({ reason, userId } = {}) => {
|
||||||
|
if (!userId || !['permission_granted', 'permission_revoked', 'identify'].includes(reason)) return;
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (getUserIdForSocket(socket) !== userId) return;
|
||||||
|
pushLevelsForSocket(socket);
|
||||||
|
// Permission changes alter both effective rover output and the controls the
|
||||||
|
// browser may use, so each affected connection receives a fresh session.
|
||||||
|
emitChange('personal_adjustment_permission_changed', { scope: 'socket', socketId: socket.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket } = {}) => {
|
||||||
|
// Administrators implicitly have this capability, so login/logout can change
|
||||||
|
// the effective adjustment even though no database permission row changed.
|
||||||
|
if (socket) pushLevelsForSocket(socket);
|
||||||
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||||
cb({ success: true, levels: getAudioLevels() });
|
cb({ success: true, levels: getAudioLevels() });
|
||||||
@@ -144,13 +311,49 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:setPersonalAdjustmentRange', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
throw new Error('Not authorized');
|
||||||
|
}
|
||||||
|
const actor = socket?.data?.user?.username || null;
|
||||||
|
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
|
||||||
|
cb({ success: true, maxPersonalAdjustmentPercent });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
ADJUSTMENT_FIELDS,
|
||||||
|
PERSONAL_ADJUSTMENT_PERMISSION,
|
||||||
|
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||||
getAudioLevels,
|
getAudioLevels,
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
|
setMaxPersonalAdjustmentPercent,
|
||||||
|
setSocketAdjustments,
|
||||||
|
getEffectiveLevelsForSocket,
|
||||||
|
getAudioAdjustmentStateForSocket,
|
||||||
pushLevelsToRover,
|
pushLevelsToRover,
|
||||||
audioLevelsEvents,
|
audioLevelsEvents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Balance Board Hardware Bridge
|
||||||
|
// Purpose: Supervises the capability-limited native worker and converts its JSON-line protocol into service events.
|
||||||
|
// Scope: Owns process lifecycle, restart recovery, shutdown, and protocol validation; scale policy remains in index.js.
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WORKER_PATH =
|
||||||
|
process.env.BALANCE_BOARD_WORKER ||
|
||||||
|
path.join(__dirname, 'native', 'balance_board_worker');
|
||||||
|
const RESTART_DELAY_MS = 2000;
|
||||||
|
const STDERR_LOG_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
function createBalanceBoardHardware({ logger, address = '', simulate = false } = {}) {
|
||||||
|
const events = new EventEmitter();
|
||||||
|
let worker = null;
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
let stopped = false;
|
||||||
|
let restarting = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
let lastStderrLogAt = 0;
|
||||||
|
let suppressedStderrLines = 0;
|
||||||
|
let currentAddress = address;
|
||||||
|
|
||||||
|
function emitProtocolError(message) {
|
||||||
|
events.emit('message', {
|
||||||
|
type: 'status',
|
||||||
|
state: 'error',
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function processStdout(chunk) {
|
||||||
|
stdoutBuffer += chunk.toString('utf8');
|
||||||
|
let newline = stdoutBuffer.indexOf('\n');
|
||||||
|
while (newline !== -1) {
|
||||||
|
const line = stdoutBuffer.slice(0, newline).trim();
|
||||||
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
||||||
|
if (line) {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||||
|
throw new Error('message needs a type');
|
||||||
|
}
|
||||||
|
events.emit('message', message);
|
||||||
|
} catch (err) {
|
||||||
|
// A corrupted stdout line means measurement framing can no longer be
|
||||||
|
// trusted. Surface the exact line rather than silently discarding a
|
||||||
|
// potential hardware failure that would otherwise look like zero kg.
|
||||||
|
emitProtocolError(`balance board worker returned invalid JSON: ${err.message}`);
|
||||||
|
logger?.warn?.('Balance Board worker protocol error', { line, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newline = stdoutBuffer.indexOf('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRestart() {
|
||||||
|
if (stopped || restartTimer) return;
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = null;
|
||||||
|
start();
|
||||||
|
}, RESTART_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopped || (worker && !worker.killed)) return;
|
||||||
|
stdoutBuffer = '';
|
||||||
|
|
||||||
|
const child = spawn(WORKER_PATH, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
BALANCE_BOARD_ADDRESS: currentAddress || '',
|
||||||
|
BALANCE_BOARD_SIMULATE: simulate ? 'cycle' : '',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
worker = child;
|
||||||
|
|
||||||
|
child.stdout.on('data', processStdout);
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
const text = chunk.toString('utf8').trim();
|
||||||
|
if (!text) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastStderrLogAt >= STDERR_LOG_INTERVAL_MS) {
|
||||||
|
const suffix = suppressedStderrLines
|
||||||
|
? ` (${suppressedStderrLines} worker stderr lines suppressed)`
|
||||||
|
: '';
|
||||||
|
logger?.warn?.(`Balance Board worker: ${text}${suffix}`);
|
||||||
|
lastStderrLogAt = now;
|
||||||
|
suppressedStderrLines = 0;
|
||||||
|
} else {
|
||||||
|
suppressedStderrLines += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (worker === child) worker = null;
|
||||||
|
emitProtocolError(`balance board worker failed to start: ${err.message}`);
|
||||||
|
scheduleRestart();
|
||||||
|
});
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
if (worker === child) worker = null;
|
||||||
|
if (!stopped) {
|
||||||
|
// Admin unpair deliberately replaces the worker with an empty address.
|
||||||
|
// Do not turn that expected exit into a red hardware-error state while
|
||||||
|
// still using the normal restart scheduler for the replacement.
|
||||||
|
if (!restarting) emitProtocolError(`balance board worker exited (${signal || code})`);
|
||||||
|
restarting = false;
|
||||||
|
scheduleRestart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
stopped = true;
|
||||||
|
restarting = false;
|
||||||
|
if (restartTimer) {
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
restartTimer = null;
|
||||||
|
}
|
||||||
|
if (!worker) return;
|
||||||
|
const child = worker;
|
||||||
|
worker = null;
|
||||||
|
try {
|
||||||
|
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||||
|
} catch (_err) {
|
||||||
|
// The worker may have already closed stdin while its exit event is still
|
||||||
|
// queued. SIGTERM below remains the reliable cleanup path.
|
||||||
|
}
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
// bluetoothctl may still be finishing a bounded pairing command inside a
|
||||||
|
// worker thread. Do not let that delay server shutdown indefinitely.
|
||||||
|
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||||
|
}, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restart() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (!worker) {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = worker;
|
||||||
|
restarting = true;
|
||||||
|
try {
|
||||||
|
// An admin forget changes the address used in the child environment. A
|
||||||
|
// controlled restart lets the replacement worker start with that new
|
||||||
|
// value, while the existing close handler remains the single owner of
|
||||||
|
// delayed respawn and avoids overlapping Bluetooth listeners.
|
||||||
|
child.stdin.write(`${JSON.stringify({ command: 'stop' })}\n`);
|
||||||
|
} catch (_err) {
|
||||||
|
// The child may have already closed stdin; SIGTERM below still guarantees
|
||||||
|
// that it cannot keep listening for the address that was just forgotten.
|
||||||
|
}
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
|
||||||
|
}, 1500).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
restart,
|
||||||
|
setAddress(nextAddress) {
|
||||||
|
// The factory can be created before first commissioning. Preserve the
|
||||||
|
// newly paired address for later bridge restarts in the same Node process
|
||||||
|
// instead of reverting the replacement worker to discovery mode.
|
||||||
|
currentAddress = typeof nextAddress === 'string' ? nextAddress.trim().toUpperCase() : '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createBalanceBoardHardware,
|
||||||
|
};
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
// Balance Board Service
|
||||||
|
// Purpose: Exposes one Wii Balance Board as a self-pairing Bluetooth scale.
|
||||||
|
// Scope: Stores pairing and admin zero calibration, then publishes status plus live four-corner weight.
|
||||||
|
const fs = require('fs');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('balanceBoardService');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
|
const { isAdmin } = require('../roleService');
|
||||||
|
const { sendAlert } = require('../alertService');
|
||||||
|
const { createBalanceBoardHardware } = require('./hardware');
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
const enabled = isFeatureEnabled('balanceBoard');
|
||||||
|
const rawConfig = loadConfig().balanceBoard || {};
|
||||||
|
const DATA_DIR = resolveDataDir();
|
||||||
|
const STORE_PATH = resolveDataPath('balance-board.json');
|
||||||
|
const FRAME_ROOM = 'balance-board-viewers';
|
||||||
|
const CORNER_KEYS = ['topRight', 'bottomRight', 'topLeft', 'bottomLeft'];
|
||||||
|
const ZERO_SAMPLE_COUNT = 10;
|
||||||
|
const ZERO_SAMPLE_INTERVAL_MS = 1000;
|
||||||
|
const ZERO_MAX_SAMPLE_AGE_MS = 1500;
|
||||||
|
const ZERO_MAX_COMBINED_RANGE_KG = 0.5;
|
||||||
|
const RECORD_PERSIST_DELAY_MS = 1000;
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const ALERT_COLOR = '#38bdf8';
|
||||||
|
|
||||||
|
function emptyZeroCorners() {
|
||||||
|
return Object.fromEntries(CORNER_KEYS.map((key) => [key, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoredCorners(value) {
|
||||||
|
if (!value || typeof value !== 'object') return emptyZeroCorners();
|
||||||
|
return Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||||
|
const number = Number(value[key]);
|
||||||
|
return [key, Number.isFinite(number) ? Math.max(0, number) : 0];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStore() {
|
||||||
|
return {
|
||||||
|
address: '',
|
||||||
|
zeroCorners: emptyZeroCorners(),
|
||||||
|
zeroedAt: null,
|
||||||
|
recordKg: 0,
|
||||||
|
recordedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStore() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
|
const address = typeof parsed?.address === 'string' ? parsed.address.trim().toUpperCase() : '';
|
||||||
|
const zeroedAt = Number.isFinite(Number(parsed?.zeroedAt)) ? Number(parsed.zeroedAt) : null;
|
||||||
|
const recordKg = Number.isFinite(Number(parsed?.recordKg))
|
||||||
|
? roundedWeight(parsed.recordKg)
|
||||||
|
: 0;
|
||||||
|
const recordedAt = Number.isFinite(Number(parsed?.recordedAt))
|
||||||
|
? Number(parsed.recordedAt)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
zeroCorners: zeroedAt ? normalizeStoredCorners(parsed.zeroCorners) : emptyZeroCorners(),
|
||||||
|
zeroedAt,
|
||||||
|
recordKg,
|
||||||
|
recordedAt: recordKg > 0 ? recordedAt : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') logger.warn('Failed to load Balance Board address', err.message);
|
||||||
|
return emptyStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistStore() {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
const temporary = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(temporary, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
||||||
|
fs.renameSync(temporary, STORE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundedWeight(value) {
|
||||||
|
return Math.round(Math.max(0, Number(value) || 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornerWeightsKg(corners = {}) {
|
||||||
|
// Preserve wiiuse's factory-calibrated load cells in kilograms. The separate
|
||||||
|
// admin zero calibration below is an installation baseline layered on top of
|
||||||
|
// this factory conversion; it must never replace the hardware calibration.
|
||||||
|
return {
|
||||||
|
topRight: roundedWeight((Number(corners.topRight) || 0) / 100),
|
||||||
|
bottomRight: roundedWeight((Number(corners.bottomRight) || 0) / 100),
|
||||||
|
topLeft: roundedWeight((Number(corners.topLeft) || 0) / 100),
|
||||||
|
bottomLeft: roundedWeight((Number(corners.bottomLeft) || 0) / 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractZero(rawCorners) {
|
||||||
|
const baseline = store.zeroedAt ? store.zeroCorners : emptyZeroCorners();
|
||||||
|
return Object.fromEntries(CORNER_KEYS.map((key) => [
|
||||||
|
key,
|
||||||
|
roundedWeight(Math.max(0, rawCorners[key] - baseline[key])),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalCornerWeight(corners) {
|
||||||
|
return roundedWeight(CORNER_KEYS.reduce((total, key) => total + corners[key], 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = enabled ? loadStore() : emptyStore();
|
||||||
|
let hardware = null;
|
||||||
|
let status = enabled ? (store.address ? 'waiting' : 'starting') : 'disabled';
|
||||||
|
let detail = enabled
|
||||||
|
? (store.address ? 'Press the front power button.' : 'Starting Bluetooth discovery.')
|
||||||
|
: 'Balance Board support is disabled.';
|
||||||
|
let connected = false;
|
||||||
|
let batteryPercent = null;
|
||||||
|
let latestFrame = null;
|
||||||
|
let latestRawCorners = null;
|
||||||
|
let latestRawFrameAt = 0;
|
||||||
|
let zeroTimer = null;
|
||||||
|
let recordPersistTimer = null;
|
||||||
|
let zeroSamples = [];
|
||||||
|
let zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
let previousWorkerState = '';
|
||||||
|
let lastAlertKey = '';
|
||||||
|
let unpairing = false;
|
||||||
|
|
||||||
|
function sendRawAlert(state, message = '') {
|
||||||
|
const rawMessage = message ? `${state}: ${message}` : state;
|
||||||
|
if (rawMessage === lastAlertKey) return;
|
||||||
|
lastAlertKey = rawMessage;
|
||||||
|
sendAlert({ color: ALERT_COLOR, title: 'Balance Board', message: rawMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendStatusAlert(workerState, message = '') {
|
||||||
|
const shouldAlert =
|
||||||
|
workerState === 'connected' ||
|
||||||
|
workerState === 'sleeping' ||
|
||||||
|
workerState === 'connection-failed' ||
|
||||||
|
workerState === 'error' ||
|
||||||
|
(workerState === 'waiting' && previousWorkerState === 'connected');
|
||||||
|
|
||||||
|
previousWorkerState = workerState;
|
||||||
|
if (!shouldAlert) return;
|
||||||
|
|
||||||
|
// Keep the alert at the same system-level boundary as the worker protocol:
|
||||||
|
// state first, followed by its exact detail when one exists. The service does
|
||||||
|
// not reinterpret failures as friendlier product copy, but still collapses
|
||||||
|
// identical retries so a failing reconnect cannot flood the activity feed.
|
||||||
|
sendRawAlert(workerState, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
paired: Boolean(store.address) || Boolean(rawConfig.simulate),
|
||||||
|
address: store.address || (rawConfig.simulate ? 'SIMULATED' : null),
|
||||||
|
connected,
|
||||||
|
status,
|
||||||
|
detail,
|
||||||
|
batteryPercent,
|
||||||
|
recordKg: store.recordKg,
|
||||||
|
recordedAt: store.recordedAt,
|
||||||
|
calibration: {
|
||||||
|
calibrated: Boolean(store.zeroedAt),
|
||||||
|
zeroedAt: store.zeroedAt,
|
||||||
|
...zeroProgress,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecordPersistTimer() {
|
||||||
|
if (!recordPersistTimer) return;
|
||||||
|
clearTimeout(recordPersistTimer);
|
||||||
|
recordPersistTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRecordPersistence() {
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
|
||||||
|
// A person driving onto the board produces many successively larger frames.
|
||||||
|
// Waiting until the maximum has stopped changing prevents a synchronous JSON
|
||||||
|
// rewrite for every 20 Hz sensor frame while still saving a settled record
|
||||||
|
// promptly enough to survive an ordinary service restart.
|
||||||
|
recordPersistTimer = setTimeout(() => {
|
||||||
|
recordPersistTimer = null;
|
||||||
|
persistStore();
|
||||||
|
}, RECORD_PERSIST_DELAY_MS);
|
||||||
|
recordPersistTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishLatestFrame() {
|
||||||
|
if (!latestFrame) return;
|
||||||
|
io.to(FRAME_ROOM).emit('balanceBoard:frame', latestFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWeightRecord() {
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
|
||||||
|
// Reset means "start measuring the record from now." If the board currently
|
||||||
|
// has a load, that current measurement is the first candidate in the new
|
||||||
|
// period. Saving it immediately avoids briefly showing zero before the next
|
||||||
|
// live frame restores the same weight as the record.
|
||||||
|
const currentWeight = connected && latestFrame ? roundedWeight(latestFrame.totalKg) : 0;
|
||||||
|
store.recordKg = currentWeight;
|
||||||
|
store.recordedAt = currentWeight > 0 ? Date.now() : null;
|
||||||
|
persistStore();
|
||||||
|
|
||||||
|
if (latestFrame) {
|
||||||
|
latestFrame = {
|
||||||
|
...latestFrame,
|
||||||
|
recordKg: store.recordKg,
|
||||||
|
recordedAt: store.recordedAt,
|
||||||
|
};
|
||||||
|
publishLatestFrame();
|
||||||
|
}
|
||||||
|
events.emit('change', { state: getState() });
|
||||||
|
sendRawAlert('record-reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(nextStatus, nextDetail) {
|
||||||
|
const normalizedStatus = String(nextStatus || 'unknown');
|
||||||
|
const normalizedDetail = String(nextDetail || '');
|
||||||
|
if (status === normalizedStatus && detail === normalizedDetail) return;
|
||||||
|
status = normalizedStatus;
|
||||||
|
detail = normalizedDetail;
|
||||||
|
events.emit('change', { state: getState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishCalibrationState() {
|
||||||
|
// Calibration progress belongs in the ordinary session payload because it
|
||||||
|
// changes only once per second for ten seconds. Live 20 Hz weights remain in
|
||||||
|
// their dedicated room and never trigger a full-session broadcast.
|
||||||
|
events.emit('change', { state: getState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearZeroTimer() {
|
||||||
|
if (!zeroTimer) return;
|
||||||
|
clearInterval(zeroTimer);
|
||||||
|
zeroTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failZeroCalibration(error, { alert = true } = {}) {
|
||||||
|
clearZeroTimer();
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: String(error || 'Calibration failed'),
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
if (alert) sendRawAlert('zero-failed', zeroProgress.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishZeroCalibration() {
|
||||||
|
clearZeroTimer();
|
||||||
|
|
||||||
|
// A single average could hide movement that returns to its starting point.
|
||||||
|
// Sum every corner's complete ten-second range before accepting the result so
|
||||||
|
// distributed movement cannot hide below four independent thresholds. Retain
|
||||||
|
// three decimals so averaging ten centi-kilogram samples does not throw away
|
||||||
|
// useful sub-centi-kilogram precision in the persisted baseline.
|
||||||
|
const combinedRange = CORNER_KEYS.reduce((totalRange, key) => {
|
||||||
|
const values = zeroSamples.map((sample) => sample[key]);
|
||||||
|
return totalRange + Math.max(...values) - Math.min(...values);
|
||||||
|
}, 0);
|
||||||
|
if (combinedRange > ZERO_MAX_COMBINED_RANGE_KG) {
|
||||||
|
failZeroCalibration('Load moved during the ten-second calibration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.zeroCorners = Object.fromEntries(CORNER_KEYS.map((key) => {
|
||||||
|
const average = zeroSamples.reduce((sum, sample) => sum + sample[key], 0) /
|
||||||
|
zeroSamples.length;
|
||||||
|
return [key, Math.round(average * 1000) / 1000];
|
||||||
|
}));
|
||||||
|
store.zeroedAt = Date.now();
|
||||||
|
// A new zero changes the meaning of every adjusted weight, so an old record
|
||||||
|
// cannot be compared with measurements under the new baseline.
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
store.recordKg = 0;
|
||||||
|
store.recordedAt = null;
|
||||||
|
persistStore();
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: ZERO_SAMPLE_COUNT,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
sendRawAlert('zeroed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeZeroSample() {
|
||||||
|
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||||
|
failZeroCalibration('Live Balance Board data stopped during calibration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroSamples.push({ ...latestRawCorners });
|
||||||
|
zeroProgress = {
|
||||||
|
active: true,
|
||||||
|
samplesCollected: zeroSamples.length,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
if (zeroSamples.length >= ZERO_SAMPLE_COUNT) finishZeroCalibration();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startZeroCalibration() {
|
||||||
|
if (zeroProgress.active) throw new Error('Balance Board zero calibration is already running');
|
||||||
|
if (!connected || !latestRawCorners || Date.now() - latestRawFrameAt > ZERO_MAX_SAMPLE_AGE_MS) {
|
||||||
|
throw new Error('The Balance Board must be connected and sending weight data');
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: true,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
publishCalibrationState();
|
||||||
|
sendRawAlert('zeroing');
|
||||||
|
// Delaying the first sample by one interval makes this a real ten-second
|
||||||
|
// calibration rather than ten rapid reads followed by nine seconds of UI.
|
||||||
|
zeroTimer = setInterval(takeZeroSample, ZERO_SAMPLE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processFrame(message = {}) {
|
||||||
|
const rawCorners = cornerWeightsKg(message.corners);
|
||||||
|
latestRawCorners = rawCorners;
|
||||||
|
latestRawFrameAt = Date.now();
|
||||||
|
if (Number.isFinite(Number(message.batteryPercent))) {
|
||||||
|
batteryPercent = Math.max(0, Math.min(100, Number(message.batteryPercent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
connected = true;
|
||||||
|
updateStatus('connected', 'Live weight is updating.');
|
||||||
|
const adjustedCorners = subtractZero(rawCorners);
|
||||||
|
const totalKg = totalCornerWeight(adjustedCorners);
|
||||||
|
if (totalKg > store.recordKg) {
|
||||||
|
// Store only adjusted weight so the displayed record uses the same admin
|
||||||
|
// zero baseline as the live total and all four corner readings.
|
||||||
|
store.recordKg = totalKg;
|
||||||
|
store.recordedAt = Date.now();
|
||||||
|
scheduleRecordPersistence();
|
||||||
|
}
|
||||||
|
latestFrame = {
|
||||||
|
totalKg,
|
||||||
|
corners: adjustedCorners,
|
||||||
|
batteryPercent,
|
||||||
|
recordKg: store.recordKg,
|
||||||
|
recordedAt: store.recordedAt,
|
||||||
|
};
|
||||||
|
publishLatestFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkerMessage(message = {}) {
|
||||||
|
if (message.type === 'frame') {
|
||||||
|
processFrame(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'paired') {
|
||||||
|
const address = typeof message.address === 'string' ? message.address.trim().toUpperCase() : '';
|
||||||
|
if (address && address !== store.address) {
|
||||||
|
store.address = address;
|
||||||
|
// A zero baseline belongs to one physical board and whatever permanent
|
||||||
|
// platform/load was present when an admin calibrated it. Never carry that
|
||||||
|
// baseline across commissioning a different Bluetooth identity.
|
||||||
|
store.zeroCorners = emptyZeroCorners();
|
||||||
|
store.zeroedAt = null;
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
store.recordKg = 0;
|
||||||
|
store.recordedAt = null;
|
||||||
|
persistStore();
|
||||||
|
}
|
||||||
|
hardware?.setAddress(address);
|
||||||
|
sendRawAlert('paired');
|
||||||
|
updateStatus('connecting', 'Paired. Connecting to the board now.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type !== 'status') return;
|
||||||
|
const workerState = String(message.state || 'unknown');
|
||||||
|
sendStatusAlert(workerState, message.error || '');
|
||||||
|
if (workerState === 'commissioning') {
|
||||||
|
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||||
|
} else if (workerState === 'discovering') {
|
||||||
|
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
|
||||||
|
} else if (workerState === 'device-detected') {
|
||||||
|
// Preserve the worker's exact identification stage instead of leaving the
|
||||||
|
// panel apparently unchanged when an adapter sees only the board's address.
|
||||||
|
// This is intentionally not a feed alert because ambient unresolved devices
|
||||||
|
// can appear during commissioning and the state is already visible locally.
|
||||||
|
updateStatus(
|
||||||
|
'identifying',
|
||||||
|
message.error || 'Bluetooth device detected; checking whether it is the Balance Board.',
|
||||||
|
);
|
||||||
|
} else if (workerState === 'pairing') {
|
||||||
|
updateStatus('pairing', 'Board found. Pairing now.');
|
||||||
|
} else if (workerState === 'connected') {
|
||||||
|
connected = true;
|
||||||
|
updateStatus('connected', 'Connected. Waiting for live weight data.');
|
||||||
|
} else if (workerState === 'link-detected') {
|
||||||
|
connected = false;
|
||||||
|
// The native bridge can now distinguish which half of the board's HID
|
||||||
|
// connection reached the server. Preserve that diagnostic until both
|
||||||
|
// channels arrive; the generic text remains for the outbound Sync flow.
|
||||||
|
updateStatus('connecting', message.error || 'Board responded. Reading its sensor calibration.');
|
||||||
|
} else if (workerState === 'connection-failed') {
|
||||||
|
connected = false;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||||
|
updateStatus('connection-failed', message.error || 'The direct Balance Board connection failed.');
|
||||||
|
} else if (workerState === 'sleeping') {
|
||||||
|
connected = false;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Board slept during calibration.');
|
||||||
|
updateStatus('sleeping', message.error || 'Board is asleep. Press the front power button to wake it.');
|
||||||
|
} else if (workerState === 'waiting') {
|
||||||
|
connected = false;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Board disconnected during calibration.');
|
||||||
|
updateStatus('waiting', message.error || 'Press the front power button. The server will keep trying to connect.');
|
||||||
|
} else if (workerState === 'error') {
|
||||||
|
connected = false;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
if (zeroProgress.active) failZeroCalibration('Worker stopped during calibration.');
|
||||||
|
updateStatus('error', message.error || 'The Balance Board worker stopped.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('balanceBoard:subscribe', (_payload = {}, cb = () => {}) => {
|
||||||
|
socket.join(FRAME_ROOM);
|
||||||
|
if (latestFrame) socket.emit('balanceBoard:frame', latestFrame);
|
||||||
|
cb({ success: true });
|
||||||
|
});
|
||||||
|
socket.on('balanceBoard:unsubscribe', () => socket.leave(FRAME_ROOM));
|
||||||
|
socket.on('balanceBoard:zero', (_payload = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startZeroCalibration();
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message || 'Failed to start Balance Board zero calibration' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('balanceBoard:resetRecord', (_payload = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resetWeightRecord();
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to reset Balance Board weight record', err);
|
||||||
|
cb({ error: err.message || 'Failed to reset the Balance Board weight record' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('balanceBoard:unpair', async (_payload = {}, cb = () => {}) => {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
cb({ error: 'Admin access required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unpairing) {
|
||||||
|
cb({ error: 'The Balance Board is already being unpaired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unpairing = true;
|
||||||
|
const address = store.address;
|
||||||
|
let bluetoothWarning = '';
|
||||||
|
try {
|
||||||
|
if (address) {
|
||||||
|
try {
|
||||||
|
// A complete forget removes both sources of remembered identity. If
|
||||||
|
// only the JSON address or only the BlueZ bond were removed, the next
|
||||||
|
// red-Sync attempt could inherit half of the previous relationship.
|
||||||
|
await execFileAsync('bluetoothctl', ['remove', address], { timeout: 10000 });
|
||||||
|
} catch (err) {
|
||||||
|
bluetoothWarning = String(
|
||||||
|
err?.stderr || err?.message || 'BlueZ did not remove the bond',
|
||||||
|
).trim();
|
||||||
|
logger.warn('Balance Board BlueZ bond removal failed', bluetoothWarning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.address = '';
|
||||||
|
store.zeroCorners = emptyZeroCorners();
|
||||||
|
store.zeroedAt = null;
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
store.recordKg = 0;
|
||||||
|
store.recordedAt = null;
|
||||||
|
persistStore();
|
||||||
|
clearZeroTimer();
|
||||||
|
zeroSamples = [];
|
||||||
|
zeroProgress = {
|
||||||
|
active: false,
|
||||||
|
samplesCollected: 0,
|
||||||
|
totalSamples: ZERO_SAMPLE_COUNT,
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
connected = false;
|
||||||
|
batteryPercent = null;
|
||||||
|
latestFrame = null;
|
||||||
|
latestRawCorners = null;
|
||||||
|
latestRawFrameAt = 0;
|
||||||
|
previousWorkerState = '';
|
||||||
|
hardware?.setAddress('');
|
||||||
|
hardware?.restart();
|
||||||
|
updateStatus('starting', 'Starting Bluetooth discovery.');
|
||||||
|
sendRawAlert('unpaired');
|
||||||
|
cb({ success: true, warning: bluetoothWarning || null });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to unpair Balance Board', err);
|
||||||
|
cb({ error: err.message || 'Failed to unpair the Balance Board' });
|
||||||
|
} finally {
|
||||||
|
unpairing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
hardware = createBalanceBoardHardware({
|
||||||
|
logger,
|
||||||
|
address: store.address,
|
||||||
|
simulate: Boolean(rawConfig.simulate || process.env.BALANCE_BOARD_SIMULATE),
|
||||||
|
});
|
||||||
|
hardware.events.on('message', handleWorkerMessage);
|
||||||
|
hardware.start();
|
||||||
|
} else {
|
||||||
|
logger.info('Balance Board disabled by config');
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShutdownHooks() {
|
||||||
|
const shutdown = () => {
|
||||||
|
clearZeroTimer();
|
||||||
|
// A record may still be inside the short debounce window when the process
|
||||||
|
// receives a normal shutdown signal. Flush that newest maximum before the
|
||||||
|
// hardware worker stops so a clean restart cannot lose it.
|
||||||
|
if (recordPersistTimer) {
|
||||||
|
clearRecordPersistTimer();
|
||||||
|
persistStore();
|
||||||
|
}
|
||||||
|
hardware?.stop();
|
||||||
|
};
|
||||||
|
process.once('exit', shutdown);
|
||||||
|
process.once('SIGINT', shutdown);
|
||||||
|
process.once('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
installShutdownHooks();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getState,
|
||||||
|
balanceBoardEvents: events,
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CXX ?= g++
|
||||||
|
|
||||||
|
# Wiiuse owns the Balance Board's HID control/interrupt channels and applies the
|
||||||
|
# calibration stored in the board. This deliberately avoids BlueZ's generic HID
|
||||||
|
# profile: current BlueZ requests medium link security for a bonded board, and
|
||||||
|
# the original Balance Board rejects that negotiation before an input device is
|
||||||
|
# created.
|
||||||
|
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||||
|
LDLIBS += -lwiiuse -lbluetooth -pthread
|
||||||
|
|
||||||
|
TARGET := balance_board_worker
|
||||||
|
SRC := balance_board_worker.cpp
|
||||||
|
|
||||||
|
.PHONY: all clean
|
||||||
|
|
||||||
|
all: $(TARGET)
|
||||||
|
|
||||||
|
$(TARGET): $(SRC)
|
||||||
|
$(CXX) $(CXXFLAGS) -o $@ $< $(LDLIBS)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(TARGET)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,9 @@ function createButtonBoxCore(deps) {
|
|||||||
getHomeAssistantState,
|
getHomeAssistantState,
|
||||||
setHomeAssistantEntityState,
|
setHomeAssistantEntityState,
|
||||||
setHomeAssistantLightsLockedOn,
|
setHomeAssistantLightsLockedOn,
|
||||||
|
setGreenMode,
|
||||||
|
isGreenModeEnabled,
|
||||||
|
onGreenModeChange,
|
||||||
store,
|
store,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
@@ -215,6 +218,11 @@ function createButtonBoxCore(deps) {
|
|||||||
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
|
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
|
||||||
setHomeAssistantLightsLockedOn: (next, options = {}) =>
|
setHomeAssistantLightsLockedOn: (next, options = {}) =>
|
||||||
setHomeAssistantLightsLockedOn(next, options),
|
setHomeAssistantLightsLockedOn(next, options),
|
||||||
|
// Rewards receive the standalone feature boundary rather than reaching
|
||||||
|
// into Home Assistant or duplicating green-mode state and alerts.
|
||||||
|
setGreenMode: (next, options = {}) => setGreenMode(next, options),
|
||||||
|
isGreenModeEnabled: () => isGreenModeEnabled(),
|
||||||
|
onGreenModeChange: (listener) => onGreenModeChange(listener),
|
||||||
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
|
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
|
||||||
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
|
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
|||||||
const { createButtonBoxStore } = require('./store');
|
const { createButtonBoxStore } = require('./store');
|
||||||
const { createButtonBoxCore } = require('./core');
|
const { createButtonBoxCore } = require('./core');
|
||||||
const { registerButtonBoxRoute } = require('./httpRoute');
|
const { registerButtonBoxRoute } = require('./httpRoute');
|
||||||
|
const greenModeService = require('../greenModeService');
|
||||||
|
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||||
@@ -61,6 +62,14 @@ const core = createButtonBoxCore({
|
|||||||
getHomeAssistantState,
|
getHomeAssistantState,
|
||||||
setHomeAssistantEntityState,
|
setHomeAssistantEntityState,
|
||||||
setHomeAssistantLightsLockedOn,
|
setHomeAssistantLightsLockedOn,
|
||||||
|
setGreenMode: greenModeService.setEnabled,
|
||||||
|
isGreenModeEnabled: greenModeService.isEnabled,
|
||||||
|
// Return an explicit cleanup function so timed rewards can stop observing
|
||||||
|
// the global service when they expire, rerun, or are recovered.
|
||||||
|
onGreenModeChange: (listener) => {
|
||||||
|
greenModeService.greenModeEvents.on('change', listener);
|
||||||
|
return () => greenModeService.greenModeEvents.off('change', listener);
|
||||||
|
},
|
||||||
store,
|
store,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
// Scope: Owns message validation pipeline and typed outbound message construction.
|
// Scope: Owns message validation pipeline and typed outbound message construction.
|
||||||
const logger = require('../../globals/logger').child('chatService');
|
const logger = require('../../globals/logger').child('chatService');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
|
const { isDeterred, isMuted } = require('../verificationService');
|
||||||
const { withinRateLimit } = require('./state');
|
const { withinRateLimit } = require('./state');
|
||||||
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
|
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
|
||||||
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
||||||
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
||||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||||
const { runChatTextCommand } = require('./textCommands');
|
const { isTextCommand, runChatTextCommand } = require('./textCommands');
|
||||||
|
|
||||||
function createHandlers({ sendSystemMessage }) {
|
function createHandlers({ sendSystemMessage }) {
|
||||||
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
async function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||||
@@ -17,6 +18,12 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
const clean = normalized.trim();
|
const clean = normalized.trim();
|
||||||
if (!clean) return cb({ error: 'Message required' });
|
if (!clean) return cb({ error: 'Message required' });
|
||||||
|
/*
|
||||||
|
Mute is narrower than deterrence: the socket may continue driving and
|
||||||
|
using ordinary features, but its message must stop before broadcast,
|
||||||
|
command parsing, TTS, or any other chat-derived side effect occurs.
|
||||||
|
*/
|
||||||
|
if (isMuted(socket)) return cb({ error: 'Muted' });
|
||||||
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
|
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
|
||||||
// This service no longer enforces a character-count ceiling for chat text.
|
// This service no longer enforces a character-count ceiling for chat text.
|
||||||
// The chat layer only rejects empty, rate-limited, or moderated content so
|
// The chat layer only rejects empty, rate-limited, or moderated content so
|
||||||
@@ -37,37 +44,59 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
const deterred = isDeterred(socket);
|
||||||
|
/*
|
||||||
|
Deterred users retain text chat, but chat must not become an indirect
|
||||||
|
hardware-control path. Suppress the rover typing note and TTS while still
|
||||||
|
constructing and broadcasting the same visible message as everyone else.
|
||||||
|
*/
|
||||||
|
if (!deterred) {
|
||||||
|
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (isPrivateClosedRoverId(message.roverId)) {
|
if (isPrivateClosedRoverId(message.roverId)) {
|
||||||
// Private-closed chat does not broadcast the text, so TTS is the only
|
// Private-closed chat does not broadcast the text, so TTS is the only
|
||||||
// delivery path. Use the same Google speech default as normal chat when
|
// delivery path. Use the same Google speech default as normal chat when
|
||||||
// the sender did not provide explicit TTS settings.
|
// the sender did not provide explicit TTS settings.
|
||||||
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
|
const forcedTts = ttsOptions || { speak: true, engine: 'chromegtts' };
|
||||||
maybeSpeak(socket, message, forcedTts);
|
if (!deterred) {
|
||||||
|
maybeSpeak(socket, message, forcedTts);
|
||||||
|
}
|
||||||
cb({ success: true, privateOnly: true });
|
cb({ success: true, privateOnly: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
maybeSendAccessNotice(message, sendSystemMessage);
|
maybeSendAccessNotice(message, sendSystemMessage);
|
||||||
maybeSpeak(socket, message, ttsOptions);
|
if (!deterred) {
|
||||||
|
maybeSpeak(socket, message, ttsOptions);
|
||||||
try {
|
|
||||||
// Commands sent from site chat should still be visible as normal chat
|
|
||||||
// messages. Running the command after broadcast preserves the user-visible
|
|
||||||
// transcript while keeping permissions and command execution entirely on
|
|
||||||
// the server.
|
|
||||||
const ranCommand = await runChatTextCommand({ text: clean, socket, sendSystemMessage });
|
|
||||||
cb({ success: true, command: ranCommand });
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Chat command failed after broadcast', { socket: socket?.id, error: err.message });
|
|
||||||
cb({ success: true, command: true, commandError: err.message || 'Command failed' });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cb({ success: true });
|
/*
|
||||||
|
Command-shaped text from a deterred user remains ordinary visible chat.
|
||||||
|
Reporting command=false prevents the client from implying that the server
|
||||||
|
accepted an action, and the command router is never invoked.
|
||||||
|
*/
|
||||||
|
const command = !deterred && isTextCommand(clean);
|
||||||
|
// Chat delivery is complete once validation, broadcast, and local side
|
||||||
|
// effects above have succeeded. A command may wait on Home Assistant,
|
||||||
|
// hardware, replay preparation, or an external transport, so tying the
|
||||||
|
// socket acknowledgement to command completion leaves the browser's send
|
||||||
|
// promise pending and makes its input state appear stuck. Acknowledge now;
|
||||||
|
// command replies continue through the normal Rover bot message stream.
|
||||||
|
cb({ success: true, command });
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
// Deliberately do not await this promise. runChatTextCommand already turns
|
||||||
|
// ordinary command failures into visible bot messages; this final catch
|
||||||
|
// protects the service from an unexpected setup/programming failure and
|
||||||
|
// cannot attempt a second acknowledgement after the UI has moved on.
|
||||||
|
void runChatTextCommand({ text: clean, socket, sendSystemMessage }).catch((err) => {
|
||||||
|
logger.warn('Chat command failed after acknowledgement', { socket: socket?.id, error: err.message });
|
||||||
|
sendSystemMessage(`Command failed: ${err.message || 'unknown error'}`, { nickname: 'Rover bot', bot: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
|
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
|
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
|
const { isDeterred, isMuted } = require('../verificationService');
|
||||||
const { typingBySocket } = require('./state');
|
const { typingBySocket } = require('./state');
|
||||||
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
|
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
|
||||||
const { broadcastTyping } = require('./broadcast');
|
const { broadcastTyping } = require('./broadcast');
|
||||||
@@ -13,11 +14,29 @@ function registerChatSocketHooks({ history, handleIncoming }) {
|
|||||||
socket.emit('chat:init', history);
|
socket.emit('chat:init', history);
|
||||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||||
socket.on('chat:typing', (payload = {}) => {
|
socket.on('chat:typing', (payload = {}) => {
|
||||||
|
/*
|
||||||
|
A muted typing packet must not leak presence or produce rover notes.
|
||||||
|
Clearing any prior state also removes a typing indicator that began
|
||||||
|
immediately before an administrator applied the mute.
|
||||||
|
*/
|
||||||
|
if (isMuted(socket)) {
|
||||||
|
const wasTyping = typingBySocket.delete(socket.id);
|
||||||
|
const roverId = resolveRoverId(socket?.id);
|
||||||
|
if (wasTyping && !isPrivateClosedRoverId(roverId)) {
|
||||||
|
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isTyping = Boolean(payload?.isTyping);
|
const isTyping = Boolean(payload?.isTyping);
|
||||||
const wasTyping = typingBySocket.get(socket.id);
|
const wasTyping = typingBySocket.get(socket.id);
|
||||||
if (isTyping) {
|
if (isTyping) {
|
||||||
typingBySocket.set(socket.id, true);
|
typingBySocket.set(socket.id, true);
|
||||||
if (!wasTyping) {
|
/*
|
||||||
|
The typing indicator is part of chat and remains available to a
|
||||||
|
deterred user. The rover note is a physical side effect, however, so
|
||||||
|
text-only deterrence suppresses that note without changing presence.
|
||||||
|
*/
|
||||||
|
if (!wasTyping && !isDeterred(socket)) {
|
||||||
const roverId = resolveRoverId(socket?.id);
|
const roverId = resolveRoverId(socket?.id);
|
||||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { getNickname } = require('../nicknameService');
|
|||||||
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
|
||||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const greenModeService = require('../greenModeService');
|
||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
const { isFeatureEnabled } = require('../../helpers/features');
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
@@ -17,9 +18,18 @@ const {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
|
const {
|
||||||
|
listUsersForAdmin,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
|
} = require('../identityService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
@@ -165,6 +175,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
// lights lock/unlock` from becoming transport-specific, and it preserves
|
// lights lock/unlock` from becoming transport-specific, and it preserves
|
||||||
// the existing session update path for all connected browsers.
|
// the existing session update path for all connected browsers.
|
||||||
homeAssistantService,
|
homeAssistantService,
|
||||||
|
greenModeService,
|
||||||
liftService,
|
liftService,
|
||||||
neatoService,
|
neatoService,
|
||||||
isFeatureEnabled,
|
isFeatureEnabled,
|
||||||
@@ -176,8 +187,15 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
|
listUsersForAdmin,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: null,
|
sendToChannel: null,
|
||||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
// Purpose: Defines the command Service module and the helpers/state used by this service unit.
|
// Purpose: Defines the command Service module and the helpers/state used by this service unit.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const EventEmitter = require('events');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const { isDeterred } = require('../verificationService');
|
const { isDeterred, isMuted } = require('../verificationService');
|
||||||
const logger = require('../../globals/logger').child('commandService');
|
const logger = require('../../globals/logger').child('commandService');
|
||||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||||
|
|
||||||
|
// Command observations are intentionally separate from the global event bus.
|
||||||
|
// Drive and motor commands can run at control-loop frequency, and publishing
|
||||||
|
// every packet onto the logging event bus would manufacture noise. Optional
|
||||||
|
// observers can aggregate this emitter without changing command delivery.
|
||||||
|
const commandEvents = new EventEmitter();
|
||||||
|
|
||||||
const pendingCommands = new Map(); // id -> { roverId }
|
const pendingCommands = new Map(); // id -> { roverId }
|
||||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||||
@@ -93,6 +101,31 @@ function issueCommand(roverId, payload) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The protection service owns decisions about when a held command must be
|
||||||
|
resent at a lower output. Injecting this raw transport function keeps those
|
||||||
|
resends on the same rover websocket path as every other server command while
|
||||||
|
avoiding a circular dependency from the protection service back into this
|
||||||
|
socket-facing module.
|
||||||
|
*/
|
||||||
|
overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
|
||||||
|
const blockedUntil = driveCooldowns.get(roverId);
|
||||||
|
const safetyCooldownActive = blockedUntil && Date.now() < blockedUntil;
|
||||||
|
if (safetyCooldownActive && getCommandMotionMagnitude(payload?.type, payload) > 0) {
|
||||||
|
/*
|
||||||
|
Private-rover and dock safety own the existing command cooldown map. A
|
||||||
|
rate-limited protection resend must respect those independent systems;
|
||||||
|
otherwise this new service could restart drive or brushes immediately
|
||||||
|
after an unrelated safety feature deliberately stopped them. Returning
|
||||||
|
false tells the protection service to retry after the cooldown instead of
|
||||||
|
recording an output that never reached the rover.
|
||||||
|
*/
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
issueCommand(roverId, payload);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
function handleAck(msg) {
|
function handleAck(msg) {
|
||||||
const pending = pendingCommands.get(msg.id);
|
const pending = pendingCommands.get(msg.id);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@@ -104,6 +137,16 @@ function handleAck(msg) {
|
|||||||
status: msg.status || 'ok',
|
status: msg.status || 'ok',
|
||||||
error: msg.error,
|
error: msg.error,
|
||||||
});
|
});
|
||||||
|
commandEvents.emit('observation', {
|
||||||
|
ts: Date.now(),
|
||||||
|
roverId: pending.roverId,
|
||||||
|
type: pending.type,
|
||||||
|
commandId: msg.id,
|
||||||
|
outcome: msg.error ? 'failed' : 'acknowledged',
|
||||||
|
latencyMs: Date.now() - pending.ts,
|
||||||
|
status: msg.status || 'ok',
|
||||||
|
error: msg.error || null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueUpdateToAllRovers() {
|
function issueUpdateToAllRovers() {
|
||||||
@@ -211,6 +254,7 @@ module.exports = {
|
|||||||
handleAck,
|
handleAck,
|
||||||
getRecentDriveActivity,
|
getRecentDriveActivity,
|
||||||
setDriveCooldown,
|
setDriveCooldown,
|
||||||
|
commandEvents,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -226,7 +270,7 @@ io.on('connection', (socket) => {
|
|||||||
if (type === 'audioLevels') {
|
if (type === 'audioLevels') {
|
||||||
throw new Error('audioLevels command is service-managed');
|
throw new Error('audioLevels command is service-managed');
|
||||||
}
|
}
|
||||||
const payload = data ? { ...data } : {};
|
let payload = data ? { ...data } : {};
|
||||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||||
reply({ ignored: true, reason: 'darknessActive' });
|
reply({ ignored: true, reason: 'darknessActive' });
|
||||||
@@ -248,6 +292,14 @@ io.on('connection', (socket) => {
|
|||||||
if (!isAdminSocket && isDeterred(socket)) {
|
if (!isAdminSocket && isDeterred(socket)) {
|
||||||
throw new Error('Not authorized');
|
throw new Error('Not authorized');
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
Both structured song commands and raw Open Interface song payloads
|
||||||
|
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
|
||||||
|
and any future browser beeper without affecting unrelated driving.
|
||||||
|
*/
|
||||||
|
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
|
||||||
|
throw new Error('Muted');
|
||||||
|
}
|
||||||
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
|
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
|
||||||
// in the same explicit admin-only branch as reboot instead of relying on
|
// in the same explicit admin-only branch as reboot instead of relying on
|
||||||
// drive ownership checks, because having a turn should not grant system
|
// drive ownership checks, because having a turn should not grant system
|
||||||
@@ -291,7 +343,31 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'drive' || type === 'motors') {
|
||||||
|
/*
|
||||||
|
Role is supplied at the command boundary because telemetry does not
|
||||||
|
identify the operator who produced the active motor intent. Admin and
|
||||||
|
lockdown commands therefore enter the service explicitly bypassed;
|
||||||
|
they are recorded for status visibility but are never scaled, blocked,
|
||||||
|
or countermanded by a later sensor frame.
|
||||||
|
*/
|
||||||
|
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||||
|
bypassed: isAdminSocket,
|
||||||
|
});
|
||||||
|
}
|
||||||
const id = issueCommand(roverId, { type, ...payload });
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
|
commandEvents.emit('observation', {
|
||||||
|
ts: Date.now(),
|
||||||
|
roverId: String(roverId),
|
||||||
|
type,
|
||||||
|
commandId: id,
|
||||||
|
outcome: 'issued',
|
||||||
|
socketId: socket.id,
|
||||||
|
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
|
||||||
|
// commands can carry arbitrary content. Their structured type/outcome
|
||||||
|
// supplies analytics without accidentally persisting secret material.
|
||||||
|
});
|
||||||
logger.info('Queued command', socket.id, roverId, type);
|
logger.info('Queued command', socket.id, roverId, type);
|
||||||
if (shouldRecordTurnActivity(type, payload)) {
|
if (shouldRecordTurnActivity(type, payload)) {
|
||||||
try {
|
try {
|
||||||
@@ -304,6 +380,14 @@ io.on('connection', (socket) => {
|
|||||||
reply({ id });
|
reply({ id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Command rejected', socket.id, err.message);
|
logger.warn('Command rejected', socket.id, err.message);
|
||||||
|
commandEvents.emit('observation', {
|
||||||
|
ts: Date.now(),
|
||||||
|
roverId: roverId ? String(roverId) : null,
|
||||||
|
type: type || 'unknown',
|
||||||
|
outcome: 'rejected',
|
||||||
|
socketId: socket.id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
reply({ error: err.message });
|
reply({ error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
createReplaySourceResolver,
|
createReplaySourceResolver,
|
||||||
createReplayCaptionBuilder,
|
createReplayCaptionBuilder,
|
||||||
startDiscordTypingLoop,
|
startDiscordTypingLoop,
|
||||||
sanitizeReplayTitleForFilename,
|
buildReplayFilename,
|
||||||
firstAttachmentFromMessage,
|
firstAttachmentFromMessage,
|
||||||
buildDiscordReplayMediaPayload,
|
buildDiscordReplayMediaPayload,
|
||||||
buildAcceptedMessage,
|
buildAcceptedMessage,
|
||||||
@@ -103,7 +103,9 @@ function createReplayCommand({
|
|||||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'uploading')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
// Keep direct Discord commands consistent with web-triggered uploads and
|
||||||
|
// with the local hosting fallback used when Discord delivery is unavailable.
|
||||||
|
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
||||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||||
const uploadMessage = await progressMessage.reply({
|
const uploadMessage = await progressMessage.reply({
|
||||||
content: body,
|
content: body,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Discord Fleet Daily Reports
|
||||||
|
// Purpose: Schedules and delivers completed-day fleet summaries to the existing admin alert channel.
|
||||||
|
// Scope: Discord owns timing/formatting/delivery; the fleet service owns evidence, analysis, and durable delivery state.
|
||||||
|
const { DateTime } = require('luxon');
|
||||||
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
function parseSendTime(value) {
|
||||||
|
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value || '').trim());
|
||||||
|
if (!match) return { hour: 8, minute: 0 };
|
||||||
|
return {
|
||||||
|
hour: Math.max(0, Math.min(23, Number(match[1]))),
|
||||||
|
minute: Math.max(0, Math.min(59, Number(match[2]))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRunAt({ zone, hour, minute }) {
|
||||||
|
const now = DateTime.now().setZone(zone);
|
||||||
|
let next = now.set({ hour, minute, second: 0, millisecond: 0 });
|
||||||
|
if (next <= now) next = next.plus({ days: 1 });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value, digits = 1) {
|
||||||
|
return Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFleetDailyReports({ logger, discordConfig, fleetConfig, fleetReportService, roverManager, sendToChannel }) {
|
||||||
|
let timer = null;
|
||||||
|
const reportConfig = fleetConfig?.discord || {};
|
||||||
|
const enabled = fleetReportService?.enabled && reportConfig.enabled !== false;
|
||||||
|
const channelId = discordConfig?.channels?.adminAlerts;
|
||||||
|
const zone = String(reportConfig.timezone || 'America/New_York');
|
||||||
|
const { hour, minute } = parseSendTime(reportConfig.sendAt);
|
||||||
|
|
||||||
|
function publicRoverIds() {
|
||||||
|
// Discord's shared admin-alert channel does not provide a per-viewer socket
|
||||||
|
// against which private-rover grants can be checked. Excluding private
|
||||||
|
// rovers here preserves the existing privacy boundary instead of assuming
|
||||||
|
// every channel reader has every private grant.
|
||||||
|
return roverManager.getRoster()
|
||||||
|
.filter((rover) => !rover?.private?.enabled)
|
||||||
|
.map((rover) => String(rover.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedDayRange() {
|
||||||
|
const end = DateTime.now().setZone(zone).startOf('day');
|
||||||
|
const start = end.minus({ days: 1 });
|
||||||
|
return {
|
||||||
|
reportDate: start.toISODate(),
|
||||||
|
since: start.toMillis(),
|
||||||
|
until: end.toMillis(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(reportDate, report) {
|
||||||
|
const totals = report.totals;
|
||||||
|
const attention = report.attention
|
||||||
|
.filter((item) => item.severity !== 'notice')
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((item) => `• ${item.roverId}: ${item.title}`)
|
||||||
|
.join('\n') || 'No material battery or efficiency changes need attention.';
|
||||||
|
const roverLines = report.rovers.map((rover) => {
|
||||||
|
const health = rover.batteryHealth || {};
|
||||||
|
const efficiency = rover.overallWhPerKm == null
|
||||||
|
? `efficiency pending (${formatNumber(rover.distanceMm / 1000, 0)} m)`
|
||||||
|
: `${formatNumber(rover.overallWhPerKm)} Wh/km`;
|
||||||
|
const capacity = health.measuredUsableMah == null
|
||||||
|
? `health collecting (${health.confidence || 'low'} confidence)`
|
||||||
|
: `${formatNumber(health.measuredUsableMah / 1000, 2)} Ah usable · ${formatNumber(health.capacityRetentionPercent)}% retained · ${health.confidence} confidence`;
|
||||||
|
return `• ${rover.name}: ${formatNumber(rover.distanceMm / 1e6, 2)} km · ${formatNumber(rover.dischargedWh, 2)} Wh · ${efficiency}\n Battery: ${capacity}`;
|
||||||
|
}
|
||||||
|
).join('\n') || 'No public rover telemetry.';
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(`Daily fleet report — ${reportDate}`)
|
||||||
|
.setColor(totals.attentionCount ? 0xf0b651 : 0x4caf50)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Fleet energy',
|
||||||
|
value: `${formatNumber(totals.distanceMm / 1e6, 2)} km · ${formatNumber(totals.dischargedWh, 2)} Wh · ${totals.overallWhPerKm == null ? 'efficiency pending' : `${formatNumber(totals.overallWhPerKm)} Wh/km`} · ${formatNumber(totals.stationaryDischargedWh, 2)} stationary Wh`,
|
||||||
|
},
|
||||||
|
{ name: 'Needs attention', value: attention.slice(0, 1024) },
|
||||||
|
{ name: 'Rovers', value: roverLines.slice(0, 1024) },
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'The server reports page contains the complete all-rovers metric table.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverPreviousDay() {
|
||||||
|
if (!enabled || !channelId) return;
|
||||||
|
const range = completedDayRange();
|
||||||
|
const existing = fleetReportService.storage.getDailyReport(range.reportDate);
|
||||||
|
if (existing?.discordDeliveredAt) return;
|
||||||
|
const report = fleetReportService.getDailyReport({
|
||||||
|
since: range.since,
|
||||||
|
until: range.until,
|
||||||
|
roverIds: publicRoverIds(),
|
||||||
|
});
|
||||||
|
if (!report) return;
|
||||||
|
/*
|
||||||
|
Daily storage retains the exact metric report used for delivery, but the
|
||||||
|
Discord message intentionally has no raw JSON attachment. Admins need
|
||||||
|
actionable fleet comparisons here; the complete read-only evidence stays
|
||||||
|
on the reports page without turning routine events into notification noise.
|
||||||
|
*/
|
||||||
|
fleetReportService.storage.saveDailyReport(range.reportDate, report);
|
||||||
|
const sent = await sendToChannel(
|
||||||
|
channelId,
|
||||||
|
`Daily fleet report for ${range.reportDate}`,
|
||||||
|
{ embeds: [buildEmbed(range.reportDate, report)] },
|
||||||
|
{ parse: [] },
|
||||||
|
);
|
||||||
|
if (sent) {
|
||||||
|
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { deliveredAt: Date.now(), error: null });
|
||||||
|
} else {
|
||||||
|
fleetReportService.storage.markDailyReportDelivery(range.reportDate, { error: 'Discord delivery returned no message' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNext() {
|
||||||
|
if (!enabled || !channelId) return;
|
||||||
|
const next = nextRunAt({ zone, hour, minute });
|
||||||
|
const delay = Math.max(1000, next.toMillis() - Date.now());
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await deliverPreviousDay();
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Daily fleet report delivery failed', { error: err.message });
|
||||||
|
} finally {
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
timer.unref?.();
|
||||||
|
logger.info('Scheduled daily fleet report', { nextRunAt: next.toISO(), channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, deliverPreviousDay };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createFleetDailyReports,
|
||||||
|
};
|
||||||
@@ -41,9 +41,18 @@ const {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
|
const {
|
||||||
|
listUsersForAdmin,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
|
} = require('../identityService');
|
||||||
const {
|
const {
|
||||||
attachDmMessage: attachPrivateAccessDmMessage,
|
attachDmMessage: attachPrivateAccessDmMessage,
|
||||||
getRequestByMessageId: getPrivateAccessRequestByMessageId,
|
getRequestByMessageId: getPrivateAccessRequestByMessageId,
|
||||||
@@ -54,14 +63,17 @@ const { subscribe } = require('../eventBus');
|
|||||||
const { createPresenceManager } = require('./presence');
|
const { createPresenceManager } = require('./presence');
|
||||||
const { createChannelIO } = require('./channelIO');
|
const { createChannelIO } = require('./channelIO');
|
||||||
const { createCommandHandlers } = require('../operatorCommandService');
|
const { createCommandHandlers } = require('../operatorCommandService');
|
||||||
|
const greenModeService = require('../greenModeService');
|
||||||
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
|
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
|
||||||
const { createIntegrations } = require('./integrations');
|
const { createIntegrations } = require('./integrations');
|
||||||
|
const { createFleetDailyReports } = require('./fleetDailyReports');
|
||||||
|
const fleetReportService = require('../fleetReportService');
|
||||||
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
|
const { registerPreferredDeliveryProvider } = require('../replayDeliveryService');
|
||||||
const {
|
const {
|
||||||
DEFAULT_ALLOWED_MENTIONS,
|
DEFAULT_ALLOWED_MENTIONS,
|
||||||
createReplayCaptionBuilder,
|
createReplayCaptionBuilder,
|
||||||
startDiscordTypingLoop,
|
startDiscordTypingLoop,
|
||||||
sanitizeReplayTitleForFilename,
|
buildReplayFilename,
|
||||||
firstAttachmentFromMessage,
|
firstAttachmentFromMessage,
|
||||||
buildDiscordReplayMediaPayload,
|
buildDiscordReplayMediaPayload,
|
||||||
buildAcceptedMessage,
|
buildAcceptedMessage,
|
||||||
@@ -164,7 +176,9 @@ if (discordConfig?.channels?.replay) {
|
|||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
await progressMessage.edit({ content: buildStatusMessage(job, 'uploading'), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
}
|
}
|
||||||
const attachment = new AttachmentBuilder(buffer, { name: `${sanitizeReplayTitleForFilename(job.title)}.mp4` });
|
// Every delivery path uses the job creation time, so a Discord upload
|
||||||
|
// and a server-hosted fallback always expose the same replay filename.
|
||||||
|
const attachment = new AttachmentBuilder(buffer, { name: buildReplayFilename(job) });
|
||||||
const body = replayCaption.build({ job, usedSources, missingSources });
|
const body = replayCaption.build({ job, usedSources, missingSources });
|
||||||
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
const uploadMessage = await channelIO.sendToChannel(context.channelId, body, { files: [attachment] }, DEFAULT_ALLOWED_MENTIONS);
|
||||||
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
if (!uploadMessage) throw new Error('Discord upload did not return a message');
|
||||||
@@ -227,6 +241,7 @@ const commandDependencies = {
|
|||||||
// service into the shared command router keeps Discord and mirrored web-chat
|
// service into the shared command router keeps Discord and mirrored web-chat
|
||||||
// command behavior aligned without duplicating Home Assistant calls here.
|
// command behavior aligned without duplicating Home Assistant calls here.
|
||||||
homeAssistantService,
|
homeAssistantService,
|
||||||
|
greenModeService,
|
||||||
liftService,
|
liftService,
|
||||||
neatoService,
|
neatoService,
|
||||||
isFeatureEnabled,
|
isFeatureEnabled,
|
||||||
@@ -238,8 +253,15 @@ const commandDependencies = {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
deterUser,
|
deterUser,
|
||||||
undeterUser,
|
undeterUser,
|
||||||
|
muteUser,
|
||||||
|
unmuteUser,
|
||||||
|
listUsersForAdmin,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
setUserPermission,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: channelIO.sendToChannel,
|
sendToChannel: channelIO.sendToChannel,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
@@ -347,6 +369,17 @@ client.on('messageCreate', async (message) => {
|
|||||||
client.once('ready', () => {
|
client.once('ready', () => {
|
||||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
||||||
presence.schedulePresenceRotation();
|
presence.schedulePresenceRotation();
|
||||||
|
// Discord is only a delivery consumer. Starting its scheduler after the bot
|
||||||
|
// is ready avoids failed sends during login while the collector continues to
|
||||||
|
// operate independently of Discord availability.
|
||||||
|
createFleetDailyReports({
|
||||||
|
logger,
|
||||||
|
discordConfig,
|
||||||
|
fleetConfig: config.fleetReports || {},
|
||||||
|
fleetReportService,
|
||||||
|
roverManager,
|
||||||
|
sendToChannel: channelIO.sendToChannel,
|
||||||
|
}).start();
|
||||||
});
|
});
|
||||||
|
|
||||||
client.login(discordConfig.token).catch((err) => {
|
client.login(discordConfig.token).catch((err) => {
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
// Purpose: Defines the embed Http Service module and the helpers/state used by this service unit.
|
// Purpose: Defines the embed Http Service module and the helpers/state used by this service unit.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const { renderIndexHtml, renderOgImage } = require('../embedService');
|
const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedService');
|
||||||
|
|
||||||
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database'], async (req, res) => {
|
/*
|
||||||
|
Every client-side BrowserRouter entry point must also be an explicit HTTP
|
||||||
|
entry point. Keeping this list aligned with webui/src/main.jsx lets direct
|
||||||
|
loads and browser refreshes receive the same rendered index document as
|
||||||
|
in-app navigation. The retired desktop composition is intentionally exposed
|
||||||
|
at /old; the removed /newdrive route is intentionally absent.
|
||||||
|
*/
|
||||||
|
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const html = await renderIndexHtml(req);
|
const html = await renderIndexHtml(req);
|
||||||
res.type('html').send(html);
|
res.type('html').send(html);
|
||||||
@@ -22,3 +29,13 @@ app.get('/og/preview.png', async (req, res) => {
|
|||||||
res.status(500).send('Failed to render embed image');
|
res.status(500).send('Failed to render embed image');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/manifest.webmanifest', (req, res) => {
|
||||||
|
/*
|
||||||
|
The manifest varies with server configuration, so it is served by the
|
||||||
|
application rather than copied into Vite's static output. Revalidation
|
||||||
|
lets browsers pick up branding changes after the server is restarted.
|
||||||
|
*/
|
||||||
|
res.set('Cache-Control', 'no-cache');
|
||||||
|
res.type('application/manifest+json').send(renderWebManifest());
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,26 +2,60 @@
|
|||||||
// Purpose: Defines the embed Service module and the helpers/state used by this service unit.
|
// Purpose: Defines the embed Service module and the helpers/state used by this service unit.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
const logger = require('../../globals/logger').child('embedService');
|
||||||
const { getMode } = require('../modeManager');
|
const { getMode } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getActiveDrivers, getTurnQueues } = require('../turnService');
|
const { getActiveDrivers, getTurnQueues } = require('../turnService');
|
||||||
const { getRoomCameras } = require('../roomCameraService');
|
const { getRoomCameras } = require('../roomCameraService');
|
||||||
const { getRoomCameraState } = require('../roomCameraService');
|
const { getRoomCameraState } = require('../roomCameraService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const { resolveSiteMetadata } = require('../../helpers/siteMetadata');
|
||||||
|
|
||||||
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html');
|
const INDEX_HTML_PATH = path.join(__dirname, '..', '..', '..', 'public', 'index.html');
|
||||||
const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png');
|
const BITMAP_PATH = path.join(__dirname, '..', '..', '..', 'public', 'bitmap.png');
|
||||||
|
const ANALYTICS_HTML_PATH = resolveDataPath('analytics.html');
|
||||||
|
const ANALYTICS_PLACEHOLDER = '<!-- analytics:inject -->';
|
||||||
|
const SITE_METADATA_PLACEHOLDER = '<!-- site-metadata:inject -->';
|
||||||
|
|
||||||
const OG_WIDTH = 1200;
|
const OG_WIDTH = 1200;
|
||||||
const OG_HEIGHT = 630;
|
const OG_HEIGHT = 630;
|
||||||
const BASE_BG = { r: 8, g: 12, b: 22 };
|
|
||||||
|
|
||||||
let cachedIndexHtml = null;
|
let cachedIndexHtml = null;
|
||||||
let cachedIndexMtimeMs = 0;
|
let cachedIndexMtimeMs = 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Analytics provider markup belongs to the server operator, not to the shared
|
||||||
|
web build. Loading the snippet once at process startup makes deployment
|
||||||
|
behavior predictable: replacing analytics.html takes effect on the next
|
||||||
|
normal server restart, and no analytics configuration needs to travel over
|
||||||
|
Socket.IO or be exposed through a JSON endpoint.
|
||||||
|
|
||||||
|
This file is intentionally trusted as raw HTML. Anyone able to write files in
|
||||||
|
the server data directory already controls the deployment, and allowing a
|
||||||
|
complete head snippet is what keeps this integration compatible with Umami,
|
||||||
|
Plausible, Matomo, or a custom provider without provider-specific server code.
|
||||||
|
*/
|
||||||
|
function loadAnalyticsHeadHtml() {
|
||||||
|
if (!fs.existsSync(ANALYTICS_HTML_PATH)) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(ANALYTICS_HTML_PATH, 'utf8').trim();
|
||||||
|
} catch (err) {
|
||||||
|
/*
|
||||||
|
Analytics is observability-only, so a permissions or read error must not
|
||||||
|
prevent operators and drivers from loading the rover controls.
|
||||||
|
*/
|
||||||
|
logger.warn('Unable to read analytics head HTML; continuing without analytics', err.message);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsHeadHtml = loadAnalyticsHeadHtml();
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -52,6 +86,20 @@ function getBaseUrl(req) {
|
|||||||
return `${proto}://${host}`;
|
return `${proto}://${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPagePath(req) {
|
||||||
|
/*
|
||||||
|
Canonical URLs should describe the page rather than a tracking/query
|
||||||
|
variant of it. Express's path value excludes the query string and is safe
|
||||||
|
to combine with either the configured public URL or the current request.
|
||||||
|
*/
|
||||||
|
return req.path || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPublicUrl(baseUrl, pagePath) {
|
||||||
|
const normalizedPath = pagePath.startsWith('/') ? pagePath : `/${pagePath}`;
|
||||||
|
return `${baseUrl}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getPrimaryRoomCamera() {
|
function getPrimaryRoomCamera() {
|
||||||
const cameras = getRoomCameras();
|
const cameras = getRoomCameras();
|
||||||
if (!cameras.length) return null;
|
if (!cameras.length) return null;
|
||||||
@@ -86,33 +134,6 @@ function buildEmbedCopy(state, camera) {
|
|||||||
lockdown: 'locked',
|
lockdown: 'locked',
|
||||||
}[mode] || mode;
|
}[mode] || mode;
|
||||||
|
|
||||||
let title = 'Roomba Rover';
|
|
||||||
if (mode === 'lockdown') {
|
|
||||||
title = 'Private mode is on';
|
|
||||||
} else if (roversOnline === 0) {
|
|
||||||
title = 'Rovers offline - check back soon';
|
|
||||||
} else if (driverCount > 0) {
|
|
||||||
title = 'Rovers in use - drive a rover';
|
|
||||||
} else if (mode === 'turns') {
|
|
||||||
title = 'Controls open - jump in';
|
|
||||||
} else {
|
|
||||||
title = 'Controls open - drive a rover';
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptionParts = [];
|
|
||||||
descriptionParts.push(`${roversOnline} rover${roversOnline === 1 ? '' : 's'} online`);
|
|
||||||
if (driverCount > 0) {
|
|
||||||
descriptionParts.push(`${driverCount} driving`);
|
|
||||||
} else {
|
|
||||||
descriptionParts.push('no active drivers');
|
|
||||||
}
|
|
||||||
if (mode === 'lockdown') {
|
|
||||||
descriptionParts.push('privacy mode');
|
|
||||||
} else {
|
|
||||||
descriptionParts.push(modeLabel);
|
|
||||||
}
|
|
||||||
const description = descriptionParts.join(' | ');
|
|
||||||
|
|
||||||
const statsParts = [
|
const statsParts = [
|
||||||
`${roversOnline} online`,
|
`${roversOnline} online`,
|
||||||
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
|
driverCount > 0 ? `${driverCount} driving` : 'no drivers',
|
||||||
@@ -125,20 +146,17 @@ function buildEmbedCopy(state, camera) {
|
|||||||
|
|
||||||
const cameraLabel = camera?.name || camera?.id || 'room cam';
|
const cameraLabel = camera?.name || camera?.id || 'room cam';
|
||||||
return {
|
return {
|
||||||
title,
|
|
||||||
description,
|
|
||||||
subtitle: 'Control a live rover from your browser',
|
|
||||||
stats: statsParts.join(' | '),
|
stats: statsParts.join(' | '),
|
||||||
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
|
cameraLabel: mode === 'lockdown' ? 'Room cams hidden' : `Room cam: ${cameraLabel}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMetaTags({ title, description, imageUrl, pageUrl }) {
|
function buildMetaTags({ title, description, imageUrl, pageUrl, canonicalUrl }) {
|
||||||
const safeTitle = escapeHtml(title);
|
const safeTitle = escapeHtml(title);
|
||||||
const safeDescription = escapeHtml(description);
|
const safeDescription = escapeHtml(description);
|
||||||
const safeImage = escapeHtml(imageUrl);
|
const safeImage = escapeHtml(imageUrl);
|
||||||
const safeUrl = escapeHtml(pageUrl);
|
const safeUrl = escapeHtml(pageUrl);
|
||||||
return [
|
const tags = [
|
||||||
'<!-- embed meta -->',
|
'<!-- embed meta -->',
|
||||||
`<meta name="description" content="${safeDescription}" />`,
|
`<meta name="description" content="${safeDescription}" />`,
|
||||||
`<meta property="og:title" content="${safeTitle}" />`,
|
`<meta property="og:title" content="${safeTitle}" />`,
|
||||||
@@ -153,7 +171,27 @@ function buildMetaTags({ title, description, imageUrl, pageUrl }) {
|
|||||||
`<meta name="twitter:title" content="${safeTitle}" />`,
|
`<meta name="twitter:title" content="${safeTitle}" />`,
|
||||||
`<meta name="twitter:description" content="${safeDescription}" />`,
|
`<meta name="twitter:description" content="${safeDescription}" />`,
|
||||||
`<meta name="twitter:image" content="${safeImage}" />`,
|
`<meta name="twitter:image" content="${safeImage}" />`,
|
||||||
'<!-- /embed meta -->',
|
];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Only advertise a canonical address when the operator supplied a valid
|
||||||
|
public URL. Guessing from request headers would permanently identify a LAN
|
||||||
|
hostname or reverse-proxy hop as the public home of the instance.
|
||||||
|
*/
|
||||||
|
if (canonicalUrl) {
|
||||||
|
tags.push(`<link rel="canonical" href="${escapeHtml(canonicalUrl)}" />`);
|
||||||
|
}
|
||||||
|
tags.push('<!-- /embed meta -->');
|
||||||
|
return tags.join('\n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSiteMetadataTags(siteMetadata) {
|
||||||
|
return [
|
||||||
|
'<!-- site metadata -->',
|
||||||
|
`<meta name="theme-color" content="${escapeHtml(siteMetadata.accentColor)}" />`,
|
||||||
|
`<meta name="apple-mobile-web-app-title" content="${escapeHtml(siteMetadata.shortName)}" />`,
|
||||||
|
`<title>${escapeHtml(siteMetadata.name)}</title>`,
|
||||||
|
'<!-- /site metadata -->',
|
||||||
].join('\n ');
|
].join('\n ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,23 +203,47 @@ async function renderIndexHtml(req) {
|
|||||||
activeDrivers: getActiveDrivers(),
|
activeDrivers: getActiveDrivers(),
|
||||||
turnQueues: getTurnQueues(),
|
turnQueues: getTurnQueues(),
|
||||||
};
|
};
|
||||||
const config = loadConfig();
|
const siteMetadata = resolveSiteMetadata();
|
||||||
const pageTitle = config?.site?.title || 'Roomba Rover';
|
|
||||||
const camera = getPrimaryRoomCamera();
|
const camera = getPrimaryRoomCamera();
|
||||||
const copy = buildEmbedCopy(state, camera);
|
const copy = buildEmbedCopy(state, camera);
|
||||||
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
|
const cacheBust = Math.floor(Date.now() / (5 * 60 * 1000));
|
||||||
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
|
const imageUrl = `${baseUrl}/og/preview.png?t=${cacheBust}`;
|
||||||
const pageUrl = `${baseUrl}${req.originalUrl || '/'}`;
|
const pagePath = getPagePath(req);
|
||||||
|
const canonicalUrl = siteMetadata.publicUrl
|
||||||
|
? joinPublicUrl(siteMetadata.publicUrl, pagePath)
|
||||||
|
: null;
|
||||||
|
const pageUrl = canonicalUrl || joinPublicUrl(baseUrl, pagePath);
|
||||||
|
|
||||||
const metaBlock = buildMetaTags({
|
const metaBlock = buildMetaTags({
|
||||||
title: pageTitle,
|
title: siteMetadata.name,
|
||||||
description: copy.description,
|
description: siteMetadata.description,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
pageUrl,
|
pageUrl,
|
||||||
|
canonicalUrl,
|
||||||
});
|
});
|
||||||
|
const siteMetadataBlock = buildSiteMetadataTags(siteMetadata);
|
||||||
|
|
||||||
let html = await loadIndexHtml();
|
let html = await loadIndexHtml();
|
||||||
html = html.replace(/<title>.*?<\/title>/i, `<title>${escapeHtml(pageTitle)}</title>`);
|
/*
|
||||||
|
Prefer the explicit marker so the insertion point remains stable across
|
||||||
|
Vite output changes. The closing-head fallback also keeps deployed builds
|
||||||
|
made before the marker was introduced compatible with the runtime loader.
|
||||||
|
*/
|
||||||
|
if (html.includes(ANALYTICS_PLACEHOLDER)) {
|
||||||
|
html = html.replace(ANALYTICS_PLACEHOLDER, analyticsHeadHtml);
|
||||||
|
} else if (analyticsHeadHtml) {
|
||||||
|
html = html.replace('</head>', ` ${analyticsHeadHtml}\n </head>`);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Keeping all instance-specific head values behind one marker prevents the
|
||||||
|
built index from carrying a second set of hardcoded titles and colors.
|
||||||
|
The fallback supports an older built index during a rolling deployment.
|
||||||
|
*/
|
||||||
|
if (html.includes(SITE_METADATA_PLACEHOLDER)) {
|
||||||
|
html = html.replace(SITE_METADATA_PLACEHOLDER, siteMetadataBlock);
|
||||||
|
} else {
|
||||||
|
html = html.replace('</head>', ` ${siteMetadataBlock}\n </head>`);
|
||||||
|
}
|
||||||
if (html.includes('<!-- embed meta -->')) {
|
if (html.includes('<!-- embed meta -->')) {
|
||||||
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
|
html = html.replace(/<!-- embed meta -->[\s\S]*?<!-- \/embed meta -->/i, metaBlock);
|
||||||
} else {
|
} else {
|
||||||
@@ -190,7 +252,7 @@ async function renderIndexHtml(req) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame, accentColor, accentTextColor }) {
|
||||||
const titleSize = 64;
|
const titleSize = 64;
|
||||||
const subtitleSize = 34;
|
const subtitleSize = 34;
|
||||||
const statsSize = 30;
|
const statsSize = 30;
|
||||||
@@ -207,8 +269,8 @@ function buildOverlaySvg({ title, subtitle, stats, cameraLabel, hasFrame }) {
|
|||||||
</defs>
|
</defs>
|
||||||
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
|
<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)" />
|
||||||
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
|
<rect x="56" y="48" width="210" height="40" rx="20" fill="rgba(0,0,0,0.55)" />
|
||||||
<rect x="58" y="50" width="206" height="36" rx="18" fill="#22d3ee" />
|
<rect x="58" y="50" width="206" height="36" rx="18" fill="${accentColor}" />
|
||||||
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="#001018">
|
<text x="160" y="75" font-family="DejaVu Sans, Arial, sans-serif" font-size="20" font-weight="700" text-anchor="middle" fill="${accentTextColor}">
|
||||||
${escapeXml(badgeText)}
|
${escapeXml(badgeText)}
|
||||||
</text>
|
</text>
|
||||||
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
|
<text x="64" y="410" font-family="DejaVu Sans, Arial, sans-serif" font-size="${titleSize}" font-weight="700" fill="#ffffff">
|
||||||
@@ -235,6 +297,7 @@ async function renderOgImage() {
|
|||||||
};
|
};
|
||||||
const camera = getPrimaryRoomCamera();
|
const camera = getPrimaryRoomCamera();
|
||||||
const copy = buildEmbedCopy(state, camera);
|
const copy = buildEmbedCopy(state, camera);
|
||||||
|
const siteMetadata = resolveSiteMetadata();
|
||||||
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
|
const cameraState = state.mode === 'lockdown' || !camera ? null : getRoomCameraState(camera.id);
|
||||||
const frame = cameraState?.frame || null;
|
const frame = cameraState?.frame || null;
|
||||||
const hasFrame = Boolean(frame);
|
const hasFrame = Boolean(frame);
|
||||||
@@ -246,17 +309,20 @@ async function renderOgImage() {
|
|||||||
width: OG_WIDTH,
|
width: OG_WIDTH,
|
||||||
height: OG_HEIGHT,
|
height: OG_HEIGHT,
|
||||||
channels: 3,
|
channels: 3,
|
||||||
background: BASE_BG,
|
background: siteMetadata.backgroundColor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const overlaySvg = Buffer.from(
|
const overlaySvg = Buffer.from(
|
||||||
buildOverlaySvg({
|
buildOverlaySvg({
|
||||||
title: copy.title,
|
title: siteMetadata.name,
|
||||||
subtitle: copy.subtitle,
|
// The image must use the same resolved description as the page metadata and installed shortcut.
|
||||||
|
subtitle: siteMetadata.description,
|
||||||
stats: copy.stats,
|
stats: copy.stats,
|
||||||
cameraLabel: copy.cameraLabel,
|
cameraLabel: copy.cameraLabel,
|
||||||
hasFrame,
|
hasFrame,
|
||||||
|
accentColor: siteMetadata.accentColor,
|
||||||
|
accentTextColor: siteMetadata.accentTextColor,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -272,7 +338,36 @@ async function renderOgImage() {
|
|||||||
return base.composite(composite).png().toBuffer();
|
return base.composite(composite).png().toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderWebManifest() {
|
||||||
|
const siteMetadata = resolveSiteMetadata();
|
||||||
|
|
||||||
|
/*
|
||||||
|
The manifest is generated from the same resolved values as the HTML and
|
||||||
|
social image, so browser tabs, installed shortcuts, and launch screens do
|
||||||
|
not drift into three separately configured identities.
|
||||||
|
*/
|
||||||
|
return JSON.stringify({
|
||||||
|
name: siteMetadata.name,
|
||||||
|
short_name: siteMetadata.shortName,
|
||||||
|
description: siteMetadata.description,
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: siteMetadata.backgroundColor,
|
||||||
|
theme_color: siteMetadata.accentColor,
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/bitmap.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
renderIndexHtml,
|
renderIndexHtml,
|
||||||
renderOgImage,
|
renderOgImage,
|
||||||
|
renderWebManifest,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,629 @@
|
|||||||
|
// Fleet Report Collector
|
||||||
|
// Purpose: Converts existing server events and high-rate rover sensor frames into bounded historical evidence.
|
||||||
|
// Scope: Performs passive normalization, battery-current integration, minute aggregation, and battery-session classification.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const MINUTE_MS = 60 * 1000;
|
||||||
|
const FULL_WAIT_MS = 5 * 60 * 1000;
|
||||||
|
const SESSION_KIND_CONFIRM_SAMPLES = 3;
|
||||||
|
|
||||||
|
function finite(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimum(previous, value) {
|
||||||
|
if (value == null) return previous;
|
||||||
|
return previous == null ? value : Math.min(previous, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maximum(previous, value) {
|
||||||
|
if (value == null) return previous;
|
||||||
|
return previous == null ? value : Math.max(previous, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventRoverId(event) {
|
||||||
|
const payload = event?.payload || {};
|
||||||
|
// Generic payload `id` fields are commonly message, request, or job IDs and
|
||||||
|
// must not be mistaken for rover identities. Producers use roverId (or an
|
||||||
|
// explicit rover object) whenever the existing privacy resolver should scope
|
||||||
|
// an event to a physical rover.
|
||||||
|
return payload.roverId || payload.rover?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferVisibility(event) {
|
||||||
|
const payload = event?.payload || {};
|
||||||
|
// Producers that know a stricter visibility scope may attach it explicitly.
|
||||||
|
// Otherwise rover-scoped events are filtered later against the same visible
|
||||||
|
// roster that drives the live UI, while verification/auth details retain the
|
||||||
|
// existing lockdown-only boundary.
|
||||||
|
if (payload.visibility) return String(payload.visibility);
|
||||||
|
if (event?.source === 'verification' || event?.source === 'identity' || event?.source === 'auth') {
|
||||||
|
return 'lockdown';
|
||||||
|
}
|
||||||
|
return eventRoverId(event) ? 'rover' : 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSeverity(type = '') {
|
||||||
|
const value = String(type).toLowerCase();
|
||||||
|
if (/fault|critical|urgent|failed|failure/.test(value)) return 'critical';
|
||||||
|
if (/warn|offline|rejected|stopped|removed|denied/.test(value)) return 'warning';
|
||||||
|
if (/started|completed|online|resolved|updated/.test(value)) return 'notice';
|
||||||
|
return 'informational';
|
||||||
|
}
|
||||||
|
|
||||||
|
function batteryKey(roverId) {
|
||||||
|
// Until an admin registers a physical battery, the stable fallback keeps all
|
||||||
|
// observations attached to the rover without pretending the hardware has a
|
||||||
|
// serial number exposed by OI.
|
||||||
|
return `unregistered:${roverId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMinute(roverId, now) {
|
||||||
|
return {
|
||||||
|
roverId,
|
||||||
|
bucketTs: Math.floor(now / MINUTE_MS) * MINUTE_MS,
|
||||||
|
sampleCount: 0,
|
||||||
|
coverageMs: 0,
|
||||||
|
gapCount: 0,
|
||||||
|
chargedMah: 0,
|
||||||
|
dischargedMah: 0,
|
||||||
|
chargedWh: 0,
|
||||||
|
dischargedWh: 0,
|
||||||
|
movingDischargedWh: 0,
|
||||||
|
stationaryDischargedWh: 0,
|
||||||
|
movingMs: 0,
|
||||||
|
maximumSpeedMmPerSecond: null,
|
||||||
|
minVoltageMv: null,
|
||||||
|
maxVoltageMv: null,
|
||||||
|
voltageTotal: 0,
|
||||||
|
voltageCount: 0,
|
||||||
|
minCurrentMa: null,
|
||||||
|
maxCurrentMa: null,
|
||||||
|
currentTotal: 0,
|
||||||
|
currentCount: 0,
|
||||||
|
minTemperatureC: null,
|
||||||
|
maxTemperatureC: null,
|
||||||
|
temperatureTotal: 0,
|
||||||
|
temperatureCount: 0,
|
||||||
|
minChargeMah: null,
|
||||||
|
maxChargeMah: null,
|
||||||
|
lastChargeMah: null,
|
||||||
|
reportedCapacityMah: null,
|
||||||
|
dockedSamples: 0,
|
||||||
|
chargingSamples: 0,
|
||||||
|
commandCount: 0,
|
||||||
|
driveCommandCount: 0,
|
||||||
|
rejectedCommandCount: 0,
|
||||||
|
distanceMm: 0,
|
||||||
|
bumpCount: 0,
|
||||||
|
cliffCount: 0,
|
||||||
|
wheelDropCount: 0,
|
||||||
|
virtualWallCount: 0,
|
||||||
|
overcurrentEpisodeCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistedMinute(minute) {
|
||||||
|
return {
|
||||||
|
roverId: minute.roverId,
|
||||||
|
bucketTs: minute.bucketTs,
|
||||||
|
sampleCount: minute.sampleCount,
|
||||||
|
coverageMs: Math.round(minute.coverageMs),
|
||||||
|
gapCount: minute.gapCount,
|
||||||
|
chargedMah: minute.chargedMah,
|
||||||
|
dischargedMah: minute.dischargedMah,
|
||||||
|
chargedWh: minute.chargedWh,
|
||||||
|
dischargedWh: minute.dischargedWh,
|
||||||
|
movingDischargedWh: minute.movingDischargedWh,
|
||||||
|
stationaryDischargedWh: minute.stationaryDischargedWh,
|
||||||
|
movingMs: Math.round(minute.movingMs),
|
||||||
|
maximumSpeedMmPerSecond: minute.maximumSpeedMmPerSecond,
|
||||||
|
minVoltageMv: minute.minVoltageMv,
|
||||||
|
maxVoltageMv: minute.maxVoltageMv,
|
||||||
|
avgVoltageMv: minute.voltageCount ? minute.voltageTotal / minute.voltageCount : null,
|
||||||
|
minCurrentMa: minute.minCurrentMa,
|
||||||
|
maxCurrentMa: minute.maxCurrentMa,
|
||||||
|
avgCurrentMa: minute.currentCount ? minute.currentTotal / minute.currentCount : null,
|
||||||
|
minTemperatureC: minute.minTemperatureC,
|
||||||
|
maxTemperatureC: minute.maxTemperatureC,
|
||||||
|
avgTemperatureC: minute.temperatureCount ? minute.temperatureTotal / minute.temperatureCount : null,
|
||||||
|
minChargeMah: minute.minChargeMah,
|
||||||
|
maxChargeMah: minute.maxChargeMah,
|
||||||
|
lastChargeMah: minute.lastChargeMah,
|
||||||
|
reportedCapacityMah: minute.reportedCapacityMah,
|
||||||
|
dockedSamples: minute.dockedSamples,
|
||||||
|
chargingSamples: minute.chargingSamples,
|
||||||
|
commandCount: minute.commandCount,
|
||||||
|
driveCommandCount: minute.driveCommandCount,
|
||||||
|
rejectedCommandCount: minute.rejectedCommandCount,
|
||||||
|
distanceMm: minute.distanceMm,
|
||||||
|
bumpCount: minute.bumpCount,
|
||||||
|
cliffCount: minute.cliffCount,
|
||||||
|
wheelDropCount: minute.wheelDropCount,
|
||||||
|
virtualWallCount: minute.virtualWallCount,
|
||||||
|
overcurrentEpisodeCount: minute.overcurrentEpisodeCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function newBatterySession(roverId, kind, now, sensors, state) {
|
||||||
|
return {
|
||||||
|
roverId,
|
||||||
|
batteryKey: state.batteryKey || batteryKey(roverId),
|
||||||
|
kind,
|
||||||
|
startedAt: now,
|
||||||
|
endedAt: null,
|
||||||
|
startChargeMah: finite(sensors?.batteryChargeMah),
|
||||||
|
endChargeMah: null,
|
||||||
|
chargedMah: 0,
|
||||||
|
dischargedMah: 0,
|
||||||
|
minVoltageMv: finite(sensors?.voltageMv),
|
||||||
|
maxVoltageMv: finite(sensors?.voltageMv),
|
||||||
|
minTemperatureC: finite(sensors?.batteryTemperatureC),
|
||||||
|
maxTemperatureC: finite(sensors?.batteryTemperatureC),
|
||||||
|
sampleCount: 0,
|
||||||
|
gapCount: 0,
|
||||||
|
status: 'open',
|
||||||
|
confidence: 'low',
|
||||||
|
qualificationReason: 'session is still open',
|
||||||
|
details: {
|
||||||
|
startedFromQualifiedFull: Boolean(state.fullQualifiedAt),
|
||||||
|
fullQualifiedAt: state.fullQualifiedAt,
|
||||||
|
warnMah: finite(state.lastBatteryState?.warn),
|
||||||
|
urgentMah: finite(state.lastBatteryState?.urgent),
|
||||||
|
configuredFullMah: finite(state.lastBatteryState?.full),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function observedSessionKind(sensors) {
|
||||||
|
const code = finite(sensors?.chargingState?.code);
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase || sensors?.chargingSources?.internalCharger);
|
||||||
|
const current = finite(sensors?.currentMa);
|
||||||
|
if (docked && (code === 1 || code === 2 || code === 3 || (current != null && current > 25))) return 'charging';
|
||||||
|
if (!docked && current != null && current < -25) return 'discharging';
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCollector({ storage, logger, maximumIntegrationGapMs, minimumCapacityTestDepthPercent }) {
|
||||||
|
const roverStates = new Map();
|
||||||
|
const lastManagerSampleAt = new Map();
|
||||||
|
const diagnostics = {
|
||||||
|
startedAt: Date.now(),
|
||||||
|
eventsObserved: 0,
|
||||||
|
eventsStored: 0,
|
||||||
|
sensorFramesObserved: 0,
|
||||||
|
validBatteryFrames: 0,
|
||||||
|
integrationGaps: 0,
|
||||||
|
minuteWrites: 0,
|
||||||
|
sessionsCompleted: 0,
|
||||||
|
lastEventAt: null,
|
||||||
|
lastSensorAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function stateFor(roverId, now) {
|
||||||
|
if (!roverStates.has(roverId)) {
|
||||||
|
roverStates.set(roverId, {
|
||||||
|
roverId,
|
||||||
|
lastAt: null,
|
||||||
|
minute: makeMinute(roverId, now),
|
||||||
|
candidateKind: null,
|
||||||
|
candidateCount: 0,
|
||||||
|
sessionKind: 'idle',
|
||||||
|
session: null,
|
||||||
|
waitingSince: null,
|
||||||
|
fullQualifiedAt: null,
|
||||||
|
lastBatteryState: null,
|
||||||
|
batteryKey: storage.getActiveBattery?.(roverId)?.batteryKey || batteryKey(roverId),
|
||||||
|
lastOdometerTotalMm: null,
|
||||||
|
safety: {
|
||||||
|
bump: false,
|
||||||
|
cliff: false,
|
||||||
|
wheelDrop: false,
|
||||||
|
virtualWall: false,
|
||||||
|
overcurrent: false,
|
||||||
|
overcurrentStartedAt: null,
|
||||||
|
overcurrentSamples: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return roverStates.get(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEvent(event = {}) {
|
||||||
|
diagnostics.eventsObserved += 1;
|
||||||
|
diagnostics.lastEventAt = Date.now();
|
||||||
|
try {
|
||||||
|
const normalized = {
|
||||||
|
ts: finite(event.ts) || Date.now(),
|
||||||
|
source: String(event.source || 'unknown'),
|
||||||
|
type: String(event.type || 'unknown'),
|
||||||
|
roverId: eventRoverId(event),
|
||||||
|
visibility: inferVisibility(event),
|
||||||
|
severity: inferSeverity(event.type),
|
||||||
|
correlationId: event?.payload?.correlationId || event?.payload?.jobId || event?.payload?.sessionId || null,
|
||||||
|
payload: event.payload ?? null,
|
||||||
|
};
|
||||||
|
if (storage.insertEvent(normalized)) diagnostics.eventsStored += 1;
|
||||||
|
} catch (err) {
|
||||||
|
diagnostics.lastError = err.message;
|
||||||
|
logger.warn('Fleet collector ignored malformed domain event', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMinute(minute, sensors, elapsedMs, chargedMah, dischargedMah, chargedWh, dischargedWh, gap) {
|
||||||
|
const voltage = finite(sensors?.voltageMv);
|
||||||
|
const current = finite(sensors?.currentMa);
|
||||||
|
const temperature = finite(sensors?.batteryTemperatureC);
|
||||||
|
const charge = finite(sensors?.batteryChargeMah);
|
||||||
|
const capacity = finite(sensors?.batteryCapacityMah);
|
||||||
|
minute.sampleCount += 1;
|
||||||
|
minute.coverageMs += elapsedMs;
|
||||||
|
minute.gapCount += gap ? 1 : 0;
|
||||||
|
minute.chargedMah += chargedMah;
|
||||||
|
minute.dischargedMah += dischargedMah;
|
||||||
|
minute.chargedWh += chargedWh;
|
||||||
|
minute.dischargedWh += dischargedWh;
|
||||||
|
/*
|
||||||
|
Movement classification deliberately consumes the center speed produced
|
||||||
|
by odometerService. That service already owns encoder rollover, physical
|
||||||
|
conversion, and impossible-jump rejection; duplicating those rules here
|
||||||
|
would allow reporting and the rover's actual odometer to disagree.
|
||||||
|
*/
|
||||||
|
const speed = finite(sensors?.wheelSpeedsMmPerSecond?.center);
|
||||||
|
const moving = speed != null && Math.abs(speed) >= 1;
|
||||||
|
if (moving) {
|
||||||
|
minute.movingMs += elapsedMs;
|
||||||
|
minute.movingDischargedWh += dischargedWh;
|
||||||
|
} else {
|
||||||
|
minute.stationaryDischargedWh += dischargedWh;
|
||||||
|
}
|
||||||
|
minute.maximumSpeedMmPerSecond = maximum(minute.maximumSpeedMmPerSecond, speed == null ? null : Math.abs(speed));
|
||||||
|
minute.minVoltageMv = minimum(minute.minVoltageMv, voltage);
|
||||||
|
minute.maxVoltageMv = maximum(minute.maxVoltageMv, voltage);
|
||||||
|
if (voltage != null) { minute.voltageTotal += voltage; minute.voltageCount += 1; }
|
||||||
|
minute.minCurrentMa = minimum(minute.minCurrentMa, current);
|
||||||
|
minute.maxCurrentMa = maximum(minute.maxCurrentMa, current);
|
||||||
|
if (current != null) { minute.currentTotal += current; minute.currentCount += 1; }
|
||||||
|
minute.minTemperatureC = minimum(minute.minTemperatureC, temperature);
|
||||||
|
minute.maxTemperatureC = maximum(minute.maxTemperatureC, temperature);
|
||||||
|
if (temperature != null) { minute.temperatureTotal += temperature; minute.temperatureCount += 1; }
|
||||||
|
minute.minChargeMah = minimum(minute.minChargeMah, charge);
|
||||||
|
minute.maxChargeMah = maximum(minute.maxChargeMah, charge);
|
||||||
|
minute.lastChargeMah = charge;
|
||||||
|
minute.reportedCapacityMah = capacity;
|
||||||
|
if (sensors?.chargingSources?.homeBase) minute.dockedSamples += 1;
|
||||||
|
if (observedSessionKind(sensors) === 'charging') minute.chargingSamples += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSession(state, now, sensors, reason) {
|
||||||
|
const session = state.session;
|
||||||
|
if (!session) return;
|
||||||
|
session.endedAt = now;
|
||||||
|
session.endChargeMah = finite(sensors?.batteryChargeMah);
|
||||||
|
session.status = 'completed';
|
||||||
|
|
||||||
|
if (session.kind === 'discharging') {
|
||||||
|
const configuredFull = finite(session.details.configuredFullMah);
|
||||||
|
const startCharge = finite(session.startChargeMah);
|
||||||
|
const endCharge = finite(session.endChargeMah);
|
||||||
|
const reference = configuredFull || startCharge;
|
||||||
|
const observedDepth = reference && startCharge != null && endCharge != null
|
||||||
|
? Math.max(0, ((startCharge - endCharge) / reference) * 100)
|
||||||
|
: 0;
|
||||||
|
const reachedLowEndpoint = Boolean(
|
||||||
|
state.lastBatteryState?.urgentActive ||
|
||||||
|
(finite(session.details.urgentMah) != null && endCharge != null && endCharge <= session.details.urgentMah),
|
||||||
|
);
|
||||||
|
const qualified = Boolean(
|
||||||
|
session.details.startedFromQualifiedFull &&
|
||||||
|
reachedLowEndpoint &&
|
||||||
|
observedDepth >= minimumCapacityTestDepthPercent &&
|
||||||
|
session.gapCount === 0,
|
||||||
|
);
|
||||||
|
session.details.observedDepthPercent = observedDepth;
|
||||||
|
session.details.reachedLowEndpoint = reachedLowEndpoint;
|
||||||
|
session.details.capacityTestQualified = qualified;
|
||||||
|
if (qualified) {
|
||||||
|
session.confidence = 'high';
|
||||||
|
session.qualificationReason = 'continuous qualified-full to low-endpoint discharge';
|
||||||
|
} else if (observedDepth >= 30 && session.gapCount <= 1) {
|
||||||
|
session.confidence = 'medium';
|
||||||
|
session.qualificationReason = reason || 'useful partial discharge; not a full capacity test';
|
||||||
|
} else {
|
||||||
|
session.confidence = 'low';
|
||||||
|
session.qualificationReason = reason || 'insufficient depth, endpoint, or telemetry coverage';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.confidence = session.gapCount === 0 ? 'high' : session.gapCount <= 1 ? 'medium' : 'low';
|
||||||
|
session.qualificationReason = reason || 'charging session completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.insertBatterySession(session);
|
||||||
|
diagnostics.sessionsCompleted += 1;
|
||||||
|
state.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySessionKind(state, nextKind, now, sensors) {
|
||||||
|
if (nextKind === state.sessionKind) {
|
||||||
|
state.candidateKind = null;
|
||||||
|
state.candidateCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.candidateKind !== nextKind) {
|
||||||
|
state.candidateKind = nextKind;
|
||||||
|
state.candidateCount = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.candidateCount += 1;
|
||||||
|
if (state.candidateCount < SESSION_KIND_CONFIRM_SAMPLES) return;
|
||||||
|
|
||||||
|
finishSession(state, now, sensors, `state changed from ${state.sessionKind} to ${nextKind}`);
|
||||||
|
state.sessionKind = nextKind;
|
||||||
|
state.candidateKind = null;
|
||||||
|
state.candidateCount = 0;
|
||||||
|
if (nextKind === 'charging' || nextKind === 'discharging') {
|
||||||
|
state.session = newBatterySession(state.roverId, nextKind, now, sensors, state);
|
||||||
|
// A qualified-full marker is consumed by the next discharge. Leaving it
|
||||||
|
// set during the open session records the evidence in session details,
|
||||||
|
// while clearing it prevents later partial sessions from inheriting it.
|
||||||
|
if (nextKind === 'discharging') state.fullQualifiedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFullQualification(state, now, sensors) {
|
||||||
|
const waiting = finite(sensors?.chargingState?.code) === 4;
|
||||||
|
if (waiting) {
|
||||||
|
if (state.waitingSince == null) state.waitingSince = now;
|
||||||
|
if (now - state.waitingSince >= FULL_WAIT_MS && state.fullQualifiedAt == null) {
|
||||||
|
state.fullQualifiedAt = state.waitingSince + FULL_WAIT_MS;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.waitingSince = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSensor({ roverId, sensors, batteryState } = {}) {
|
||||||
|
diagnostics.sensorFramesObserved += 1;
|
||||||
|
diagnostics.lastSensorAt = Date.now();
|
||||||
|
if (!roverId || !sensors || finite(sensors.currentMa) == null) return;
|
||||||
|
diagnostics.validBatteryFrames += 1;
|
||||||
|
const now = Date.now();
|
||||||
|
const state = stateFor(String(roverId), now);
|
||||||
|
state.lastBatteryState = batteryState || state.lastBatteryState;
|
||||||
|
const elapsedMs = state.lastAt == null ? 0 : Math.max(0, now - state.lastAt);
|
||||||
|
const gap = elapsedMs > maximumIntegrationGapMs;
|
||||||
|
const validElapsedMs = gap ? 0 : elapsedMs;
|
||||||
|
if (gap) diagnostics.integrationGaps += 1;
|
||||||
|
const currentMa = finite(sensors.currentMa) || 0;
|
||||||
|
const deltaMah = currentMa * validElapsedMs / 3600000;
|
||||||
|
const chargedMah = Math.max(0, deltaMah);
|
||||||
|
const dischargedMah = Math.max(0, -deltaMah);
|
||||||
|
const voltageMv = finite(sensors.voltageMv);
|
||||||
|
/*
|
||||||
|
Millivolts multiplied by milliamps are microwatts. Dividing their
|
||||||
|
millisecond product by 3.6e12 therefore yields watt-hours. Integrating
|
||||||
|
voltage and current together here is required: multiplying independent
|
||||||
|
daily averages later would produce incorrect energy whenever load varies.
|
||||||
|
*/
|
||||||
|
const deltaWh = voltageMv == null ? 0 : voltageMv * currentMa * validElapsedMs / 3.6e12;
|
||||||
|
const chargedWh = Math.max(0, deltaWh);
|
||||||
|
const dischargedWh = Math.max(0, -deltaWh);
|
||||||
|
|
||||||
|
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
|
||||||
|
if (state.minute.bucketTs !== bucketTs) {
|
||||||
|
storage.upsertMinute(persistedMinute(state.minute));
|
||||||
|
diagnostics.minuteWrites += 1;
|
||||||
|
state.minute = makeMinute(state.roverId, now);
|
||||||
|
}
|
||||||
|
updateMinute(
|
||||||
|
state.minute,
|
||||||
|
sensors,
|
||||||
|
validElapsedMs,
|
||||||
|
chargedMah,
|
||||||
|
dischargedMah,
|
||||||
|
chargedWh,
|
||||||
|
dischargedWh,
|
||||||
|
gap,
|
||||||
|
);
|
||||||
|
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||||
|
const nextSafety = {
|
||||||
|
bump: Boolean(bumps.bumpLeft || bumps.bumpRight),
|
||||||
|
cliff: Boolean(sensors.cliffLeft || sensors.cliffFrontLeft || sensors.cliffFrontRight || sensors.cliffRight),
|
||||||
|
wheelDrop: Boolean(bumps.wheelDropLeft || bumps.wheelDropRight),
|
||||||
|
virtualWall: Boolean(sensors.virtualWall),
|
||||||
|
overcurrent: Boolean(
|
||||||
|
sensors?.wheelOvercurrents?.leftWheel || sensors?.wheelOvercurrents?.rightWheel ||
|
||||||
|
sensors?.wheelOvercurrents?.mainBrush || sensors?.wheelOvercurrents?.sideBrush,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (nextSafety.bump && !state.safety.bump) state.minute.bumpCount += 1;
|
||||||
|
if (nextSafety.cliff && !state.safety.cliff) state.minute.cliffCount += 1;
|
||||||
|
if (nextSafety.wheelDrop && !state.safety.wheelDrop) state.minute.wheelDropCount += 1;
|
||||||
|
if (nextSafety.virtualWall && !state.safety.virtualWall) state.minute.virtualWallCount += 1;
|
||||||
|
if (nextSafety.overcurrent) state.safety.overcurrentSamples += 1;
|
||||||
|
if (nextSafety.overcurrent && !state.safety.overcurrent) {
|
||||||
|
state.minute.overcurrentEpisodeCount += 1;
|
||||||
|
state.safety.overcurrentStartedAt = now;
|
||||||
|
state.safety.overcurrentSamples = 1;
|
||||||
|
collectEvent({
|
||||||
|
source: 'fleetReportService',
|
||||||
|
type: 'overcurrent.episode.started',
|
||||||
|
ts: now,
|
||||||
|
payload: { roverId: state.roverId, motors: sensors.wheelOvercurrents },
|
||||||
|
});
|
||||||
|
} else if (!nextSafety.overcurrent && state.safety.overcurrent) {
|
||||||
|
collectEvent({
|
||||||
|
source: 'fleetReportService',
|
||||||
|
type: 'overcurrent.episode.resolved',
|
||||||
|
ts: now,
|
||||||
|
payload: {
|
||||||
|
roverId: state.roverId,
|
||||||
|
startedAt: state.safety.overcurrentStartedAt,
|
||||||
|
durationMs: Math.max(0, now - (state.safety.overcurrentStartedAt || now)),
|
||||||
|
sampleCount: state.safety.overcurrentSamples,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.safety.overcurrentStartedAt = null;
|
||||||
|
state.safety.overcurrentSamples = 0;
|
||||||
|
}
|
||||||
|
Object.assign(state.safety, nextSafety);
|
||||||
|
updateFullQualification(state, now, sensors);
|
||||||
|
applySessionKind(state, observedSessionKind(sensors), now, sensors);
|
||||||
|
|
||||||
|
if (state.session) {
|
||||||
|
const session = state.session;
|
||||||
|
session.sampleCount += 1;
|
||||||
|
session.gapCount += gap ? 1 : 0;
|
||||||
|
session.chargedMah += chargedMah;
|
||||||
|
session.dischargedMah += dischargedMah;
|
||||||
|
session.minVoltageMv = minimum(session.minVoltageMv, finite(sensors.voltageMv));
|
||||||
|
session.maxVoltageMv = maximum(session.maxVoltageMv, finite(sensors.voltageMv));
|
||||||
|
session.minTemperatureC = minimum(session.minTemperatureC, finite(sensors.batteryTemperatureC));
|
||||||
|
session.maxTemperatureC = maximum(session.maxTemperatureC, finite(sensors.batteryTemperatureC));
|
||||||
|
}
|
||||||
|
state.lastAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCommand(command = {}) {
|
||||||
|
if (!command.roverId) return;
|
||||||
|
const now = finite(command.ts) || Date.now();
|
||||||
|
const state = stateFor(String(command.roverId), now);
|
||||||
|
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
|
||||||
|
if (state.minute.bucketTs !== bucketTs) {
|
||||||
|
if (state.minute.sampleCount || state.minute.commandCount) {
|
||||||
|
storage.upsertMinute(persistedMinute(state.minute));
|
||||||
|
diagnostics.minuteWrites += 1;
|
||||||
|
}
|
||||||
|
state.minute = makeMinute(state.roverId, now);
|
||||||
|
}
|
||||||
|
state.minute.commandCount += 1;
|
||||||
|
if (command.type === 'drive' || command.type === 'motors') state.minute.driveCommandCount += 1;
|
||||||
|
if (command.outcome === 'rejected') state.minute.rejectedCommandCount += 1;
|
||||||
|
|
||||||
|
// Drive/motor commands can arrive at control-loop frequency. Their exact
|
||||||
|
// volume belongs in minute counters, while rejections and low-frequency
|
||||||
|
// actions remain individually inspectable. This preserves operational
|
||||||
|
// depth without turning normal held movement into an event-timeline flood.
|
||||||
|
if ((command.type !== 'drive' && command.type !== 'motors') || command.outcome === 'rejected') {
|
||||||
|
collectEvent({
|
||||||
|
source: 'commandService',
|
||||||
|
type: `command.${command.outcome || 'observed'}`,
|
||||||
|
ts: now,
|
||||||
|
payload: command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectManagerEvent(kind, event = {}) {
|
||||||
|
const roverId = event.roverId ? String(event.roverId) : null;
|
||||||
|
if (kind === 'hostStats') {
|
||||||
|
const key = `${kind}:${roverId || 'unknown'}`;
|
||||||
|
const now = Date.now();
|
||||||
|
// Host statistics arrive periodically and change gradually. One exact
|
||||||
|
// sample every five minutes retains long-term diagnostic evidence while
|
||||||
|
// avoiding a timeline row for every routine host heartbeat.
|
||||||
|
if (now - (lastManagerSampleAt.get(key) || 0) < 5 * 60 * 1000) return;
|
||||||
|
lastManagerSampleAt.set(key, now);
|
||||||
|
collectEvent({
|
||||||
|
source: 'roverHost',
|
||||||
|
type: 'host.sample',
|
||||||
|
ts: event.receivedAt || now,
|
||||||
|
payload: { roverId, stats: event.stats || null },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = { ...event };
|
||||||
|
// Live rover records contain websocket handles, sets, and other runtime
|
||||||
|
// objects. The lifecycle facts are sufficient evidence and serialize
|
||||||
|
// predictably without copying those control-owned objects into storage.
|
||||||
|
delete payload.record;
|
||||||
|
collectEvent({
|
||||||
|
source: 'roverManager',
|
||||||
|
type: `roverManager.${kind}`,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOdometer({ roverId, odometer } = {}) {
|
||||||
|
if (!roverId || !odometer) return;
|
||||||
|
const now = finite(odometer.updatedAt) || Date.now();
|
||||||
|
const state = stateFor(String(roverId), now);
|
||||||
|
const totalMm = finite(odometer.totalMm);
|
||||||
|
if (totalMm == null) return;
|
||||||
|
const bucketTs = Math.floor(now / MINUTE_MS) * MINUTE_MS;
|
||||||
|
if (state.minute.bucketTs !== bucketTs) {
|
||||||
|
if (state.minute.sampleCount || state.minute.commandCount || state.minute.distanceMm) {
|
||||||
|
storage.upsertMinute(persistedMinute(state.minute));
|
||||||
|
diagnostics.minuteWrites += 1;
|
||||||
|
}
|
||||||
|
state.minute = makeMinute(state.roverId, now);
|
||||||
|
}
|
||||||
|
if (state.lastOdometerTotalMm != null && totalMm >= state.lastOdometerTotalMm) {
|
||||||
|
// Odometer total is already rollover-corrected and sanity-filtered by its
|
||||||
|
// owning service. Only non-negative increments belong in this report;
|
||||||
|
// resets establish a new baseline instead of subtracting fleet distance.
|
||||||
|
state.minute.distanceMm += totalMm - state.lastOdometerTotalMm;
|
||||||
|
}
|
||||||
|
state.lastOdometerTotalMm = totalMm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushMinutes() {
|
||||||
|
roverStates.forEach((state) => {
|
||||||
|
if (!state.minute.sampleCount && !state.minute.commandCount && !state.minute.distanceMm) return;
|
||||||
|
storage.upsertMinute(persistedMinute(state.minute));
|
||||||
|
diagnostics.minuteWrites += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLiveState() {
|
||||||
|
return Array.from(roverStates.values()).map((state) => ({
|
||||||
|
roverId: state.roverId,
|
||||||
|
lastAt: state.lastAt,
|
||||||
|
sessionKind: state.sessionKind,
|
||||||
|
waitingSince: state.waitingSince,
|
||||||
|
fullQualifiedAt: state.fullQualifiedAt,
|
||||||
|
minute: persistedMinute(state.minute),
|
||||||
|
openSession: state.session ? {
|
||||||
|
...state.session,
|
||||||
|
// The database serializer is not involved in this live response, so a
|
||||||
|
// defensive copy prevents UI consumers from mutating collector state.
|
||||||
|
details: { ...state.session.details },
|
||||||
|
} : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiagnostics() {
|
||||||
|
return {
|
||||||
|
...diagnostics,
|
||||||
|
activeRovers: roverStates.size,
|
||||||
|
openSessions: Array.from(roverStates.values()).filter((state) => state.session).length,
|
||||||
|
instanceId: crypto.createHash('sha1').update(String(diagnostics.startedAt)).digest('hex').slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshBatteryIdentity(roverId) {
|
||||||
|
const id = String(roverId || '');
|
||||||
|
if (!id) return;
|
||||||
|
const state = roverStates.get(id);
|
||||||
|
if (!state) return;
|
||||||
|
state.batteryKey = storage.getActiveBattery?.(id)?.batteryKey || batteryKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
collectEvent,
|
||||||
|
collectSensor,
|
||||||
|
collectCommand,
|
||||||
|
collectManagerEvent,
|
||||||
|
collectOdometer,
|
||||||
|
flushMinutes,
|
||||||
|
getLiveState,
|
||||||
|
getDiagnostics,
|
||||||
|
refreshBatteryIdentity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createCollector,
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Fleet Report Collector Tests
|
||||||
|
// Purpose: Verifies signed-current integration, gap rejection, and high-rate command noise reduction independently of SQLite.
|
||||||
|
// Scope: Uses an in-memory storage double so tests exercise collection policy without touching development data files.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createCollector } = require('./collector');
|
||||||
|
|
||||||
|
function makeHarness() {
|
||||||
|
const writes = { events: [], minutes: [], sessions: [] };
|
||||||
|
const storage = {
|
||||||
|
insertEvent(event) { writes.events.push(event); return { changes: 1 }; },
|
||||||
|
upsertMinute(minute) { writes.minutes.push({ ...minute }); return { changes: 1 }; },
|
||||||
|
insertBatterySession(session) { writes.sessions.push({ ...session }); return { changes: 1 }; },
|
||||||
|
};
|
||||||
|
const collector = createCollector({
|
||||||
|
storage,
|
||||||
|
logger: { warn() {} },
|
||||||
|
maximumIntegrationGapMs: 5000,
|
||||||
|
minimumCapacityTestDepthPercent: 60,
|
||||||
|
});
|
||||||
|
return { collector, writes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sensors(overrides = {}) {
|
||||||
|
return {
|
||||||
|
currentMa: -3600,
|
||||||
|
voltageMv: 14500,
|
||||||
|
batteryTemperatureC: 25,
|
||||||
|
batteryChargeMah: 2000,
|
||||||
|
batteryCapacityMah: 3000,
|
||||||
|
chargingState: { code: 0, label: 'not charging' },
|
||||||
|
chargingSources: { homeBase: false, internalCharger: false },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('integrates signed battery current while excluding long telemetry gaps', () => {
|
||||||
|
const { collector } = makeHarness();
|
||||||
|
const originalNow = Date.now;
|
||||||
|
let now = 1_000_000;
|
||||||
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
|
||||||
|
now += 1000;
|
||||||
|
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
|
||||||
|
now += 6000;
|
||||||
|
collector.collectSensor({ roverId: 'alpha', sensors: sensors() });
|
||||||
|
const live = collector.getLiveState()[0].minute;
|
||||||
|
// -3600 mA for one valid second is exactly one discharged mAh. The six
|
||||||
|
// second interval exceeds the configured integration gap and adds no
|
||||||
|
// fictional throughput.
|
||||||
|
assert.equal(live.dischargedMah, 1);
|
||||||
|
assert.equal(live.chargedMah, 0);
|
||||||
|
assert.equal(live.gapCount, 1);
|
||||||
|
assert.equal(live.coverageMs, 1000);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('integrates watt-hours and classifies energy with existing odometer speed', () => {
|
||||||
|
const { collector } = makeHarness();
|
||||||
|
const originalNow = Date.now;
|
||||||
|
let now = 1_500_000;
|
||||||
|
Date.now = () => now;
|
||||||
|
try {
|
||||||
|
collector.collectSensor({
|
||||||
|
roverId: 'alpha',
|
||||||
|
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
|
||||||
|
});
|
||||||
|
now += 1000;
|
||||||
|
collector.collectSensor({
|
||||||
|
roverId: 'alpha',
|
||||||
|
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 200, right: 200, center: 200 } }),
|
||||||
|
});
|
||||||
|
now += 1000;
|
||||||
|
collector.collectSensor({
|
||||||
|
roverId: 'alpha',
|
||||||
|
sensors: sensors({ wheelSpeedsMmPerSecond: { left: 0, right: 0, center: 0 } }),
|
||||||
|
});
|
||||||
|
const live = collector.getLiveState()[0].minute;
|
||||||
|
/*
|
||||||
|
A 14.5 V, 3.6 A discharge is 52.2 W. Two one-second intervals therefore
|
||||||
|
consume 52.2 / 1800 Wh; the first is moving and the second stationary.
|
||||||
|
This verifies that the collector uses odometer speed rather than deriving
|
||||||
|
movement from commands.
|
||||||
|
*/
|
||||||
|
assert.ok(Math.abs(live.dischargedWh - (52.2 / 1800)) < 1e-12);
|
||||||
|
assert.ok(Math.abs(live.movingDischargedWh - (52.2 / 3600)) < 1e-12);
|
||||||
|
assert.ok(Math.abs(live.stationaryDischargedWh - (52.2 / 3600)) < 1e-12);
|
||||||
|
assert.equal(live.movingMs, 1000);
|
||||||
|
assert.equal(live.maximumSpeedMmPerSecond, 200);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses cumulative odometer distance without recalculating encoder movement', () => {
|
||||||
|
const { collector } = makeHarness();
|
||||||
|
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1000, updatedAt: 4_000_000 } });
|
||||||
|
collector.collectOdometer({ roverId: 'alpha', odometer: { totalMm: 1250, updatedAt: 4_001_000 } });
|
||||||
|
const live = collector.getLiveState()[0].minute;
|
||||||
|
assert.equal(live.distanceMm, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates drive commands into minute counters instead of event noise', () => {
|
||||||
|
const { collector, writes } = makeHarness();
|
||||||
|
const originalNow = Date.now;
|
||||||
|
Date.now = () => 2_000_000;
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
collector.collectCommand({ roverId: 'alpha', type: 'drive', outcome: 'issued', ts: 2_000_000 + index });
|
||||||
|
}
|
||||||
|
collector.collectCommand({ roverId: 'alpha', type: 'drive', outcome: 'rejected', error: 'safety cooldown' });
|
||||||
|
collector.collectCommand({ roverId: 'alpha', type: 'horn', outcome: 'issued' });
|
||||||
|
const live = collector.getLiveState()[0].minute;
|
||||||
|
assert.equal(live.commandCount, 102);
|
||||||
|
assert.equal(live.driveCommandCount, 101);
|
||||||
|
assert.equal(live.rejectedCommandCount, 1);
|
||||||
|
assert.equal(writes.events.length, 2);
|
||||||
|
assert.deepEqual(writes.events.map((event) => event.type), ['command.rejected', 'command.issued']);
|
||||||
|
} finally {
|
||||||
|
Date.now = originalNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves chat content in structured global events', () => {
|
||||||
|
const { collector, writes } = makeHarness();
|
||||||
|
collector.collectEvent({
|
||||||
|
source: 'chat',
|
||||||
|
type: 'chat:message',
|
||||||
|
ts: 3_000_000,
|
||||||
|
payload: { text: 'hello fleet history', nickname: 'Otter' },
|
||||||
|
});
|
||||||
|
assert.equal(writes.events.length, 1);
|
||||||
|
assert.equal(writes.events[0].payload.text, 'hello fleet history');
|
||||||
|
assert.equal(writes.events[0].visibility, 'global');
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Fleet Report Service
|
||||||
|
// Purpose: Composes optional passive collection, storage, analysis, retention, and read-only transport.
|
||||||
|
// Scope: This is the sole feature boundary; disabled installations register no collectors, timers, database, or sockets.
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
|
const logger = require('../../globals/logger').child('fleetReportService');
|
||||||
|
|
||||||
|
if (!isFeatureEnabled('fleetReports')) {
|
||||||
|
module.exports = {
|
||||||
|
enabled: false,
|
||||||
|
getDailyReport: () => null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const { subscribeAll } = require('../eventBus');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { commandEvents } = require('../commandService');
|
||||||
|
const { odometerEvents } = require('../odometerService');
|
||||||
|
const { createStorage } = require('./storage');
|
||||||
|
const { createCollector } = require('./collector');
|
||||||
|
const { createReportBuilder } = require('./reportBuilder');
|
||||||
|
const { registerSocketGateway } = require('./socketGateway');
|
||||||
|
|
||||||
|
const config = loadConfig().fleetReports || {};
|
||||||
|
const batteryConfig = config.battery || {};
|
||||||
|
const retentionConfig = config.retention || {};
|
||||||
|
const maximumIntegrationGapMs = Math.max(
|
||||||
|
250,
|
||||||
|
(Number(batteryConfig.maximumIntegrationGapSeconds) || 5) * 1000,
|
||||||
|
);
|
||||||
|
const minimumCapacityTestDepthPercent = Math.max(
|
||||||
|
10,
|
||||||
|
Math.min(100, Number(batteryConfig.minimumCapacityTestDepthPercent) || 60),
|
||||||
|
);
|
||||||
|
const batteryEnabled = batteryConfig.enabled !== false;
|
||||||
|
const storage = createStorage({ logger });
|
||||||
|
const collector = createCollector({
|
||||||
|
storage,
|
||||||
|
logger,
|
||||||
|
maximumIntegrationGapMs,
|
||||||
|
minimumCapacityTestDepthPercent,
|
||||||
|
});
|
||||||
|
const reportBuilder = createReportBuilder({ storage, collector, roverManager });
|
||||||
|
|
||||||
|
storage.open();
|
||||||
|
const unsubscribeEvents = subscribeAll(collector.collectEvent);
|
||||||
|
if (batteryEnabled) roverManager.managerEvents.on('sensor', collector.collectSensor);
|
||||||
|
commandEvents.on('observation', collector.collectCommand);
|
||||||
|
odometerEvents.on('update', collector.collectOdometer);
|
||||||
|
const managerEventKinds = ['rover', 'hostStats', 'driver', 'switch', 'lock', 'private', 'privateSafety'];
|
||||||
|
const managerEventHandlers = new Map(managerEventKinds.map((kind) => {
|
||||||
|
const handler = (event) => collector.collectManagerEvent(kind, event);
|
||||||
|
roverManager.managerEvents.on(kind, handler);
|
||||||
|
return [kind, handler];
|
||||||
|
}));
|
||||||
|
registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger });
|
||||||
|
|
||||||
|
// Periodic upserts bound data-loss on an unclean shutdown while still
|
||||||
|
// avoiding writes at the 20 Hz sensor-frame rate.
|
||||||
|
const flushTimer = setInterval(() => collector.flushMinutes(), 30 * 1000);
|
||||||
|
flushTimer.unref?.();
|
||||||
|
|
||||||
|
function retentionDays(value, fallback) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneNow() {
|
||||||
|
const now = Date.now();
|
||||||
|
const detailedDays = retentionDays(retentionConfig.detailedDays, 0);
|
||||||
|
const minuteDays = retentionDays(retentionConfig.minuteSamplesDays, 0);
|
||||||
|
storage.prune({
|
||||||
|
detailedBefore: detailedDays === 0 ? 0 : now - detailedDays * 86400000,
|
||||||
|
minuteBefore: minuteDays === 0 ? 0 : now - minuteDays * 86400000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pruneNow();
|
||||||
|
const retentionTimer = setInterval(pruneNow, 6 * 60 * 60 * 1000);
|
||||||
|
retentionTimer.unref?.();
|
||||||
|
|
||||||
|
function getDailyReport({ since, until, roverIds } = {}) {
|
||||||
|
const end = Number(until) || Date.now();
|
||||||
|
return reportBuilder.build({
|
||||||
|
since: Number(since) || end - 24 * 60 * 60 * 1000,
|
||||||
|
until: end,
|
||||||
|
roverIds: Array.isArray(roverIds) ? roverIds : undefined,
|
||||||
|
// Daily Discord output is intentionally metric-only. Avoiding the event
|
||||||
|
// query here also prevents irrelevant event volume from bloating the
|
||||||
|
// durable daily snapshot that supports delivery idempotency.
|
||||||
|
includeEvents: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Fleet reporting enabled', {
|
||||||
|
databaseAvailable: storage.getDiagnostics().available,
|
||||||
|
maximumIntegrationGapMs,
|
||||||
|
minimumCapacityTestDepthPercent,
|
||||||
|
batteryEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
enabled: true,
|
||||||
|
getDailyReport,
|
||||||
|
collector,
|
||||||
|
storage,
|
||||||
|
reportBuilder,
|
||||||
|
// Exposed for controlled tests and graceful future shutdown wiring. Normal
|
||||||
|
// runtime leaves subscriptions active for the lifetime of the server.
|
||||||
|
stop() {
|
||||||
|
unsubscribeEvents();
|
||||||
|
if (batteryEnabled) roverManager.managerEvents.off('sensor', collector.collectSensor);
|
||||||
|
commandEvents.off('observation', collector.collectCommand);
|
||||||
|
odometerEvents.off('update', collector.collectOdometer);
|
||||||
|
managerEventHandlers.forEach((handler, kind) => roverManager.managerEvents.off(kind, handler));
|
||||||
|
clearInterval(flushTimer);
|
||||||
|
clearInterval(retentionTimer);
|
||||||
|
collector.flushMinutes();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// Fleet Report Builder
|
||||||
|
// Purpose: Produces fleet-wide battery-health and energy-efficiency read models from passive evidence.
|
||||||
|
// Scope: Keeps estimation, confidence, and comparison policy out of collection, transport, Discord, and UI code.
|
||||||
|
|
||||||
|
const MINIMUM_EFFICIENCY_DISTANCE_MM = 25 * 1000;
|
||||||
|
|
||||||
|
function sum(rows, key) {
|
||||||
|
return rows.reduce((total, row) => total + (Number(row?.[key]) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values) {
|
||||||
|
const usable = values.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
|
||||||
|
if (!usable.length) return null;
|
||||||
|
const middle = Math.floor(usable.length / 2);
|
||||||
|
return usable.length % 2 ? usable[middle] : (usable[middle - 1] + usable[middle]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedAverage(rows, valueKey, weightKey = 'sampleCount') {
|
||||||
|
const weighted = rows.reduce((result, row) => {
|
||||||
|
const value = Number(row[valueKey]);
|
||||||
|
const weight = Number(row[weightKey]);
|
||||||
|
if (!Number.isFinite(value) || !Number.isFinite(weight) || weight <= 0) return result;
|
||||||
|
result.total += value * weight;
|
||||||
|
result.weight += weight;
|
||||||
|
return result;
|
||||||
|
}, { total: 0, weight: 0 });
|
||||||
|
return weighted.weight ? weighted.total / weighted.weight : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimum(rows, key) {
|
||||||
|
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
|
||||||
|
return values.length ? Math.min(...values) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maximum(rows, key) {
|
||||||
|
const values = rows.map((row) => Number(row[key])).filter(Number.isFinite);
|
||||||
|
return values.length ? Math.max(...values) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceForObservationCount(count, averageDepthPercent) {
|
||||||
|
/*
|
||||||
|
Confidence is intentionally continuous evidence summarized into a label,
|
||||||
|
not a pass/fail cycle judgment. Multiple partial observations can become
|
||||||
|
strong evidence, while shallow observations remain visible and useful.
|
||||||
|
*/
|
||||||
|
if (count >= 5 && averageDepthPercent >= 25) return 'high';
|
||||||
|
if (count >= 2 && averageDepthPercent >= 10) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBatteryHealth({ rover, sessions, registryEntry }) {
|
||||||
|
const referenceMah = Number(registryEntry?.ratedCapacityMah) || Number(rover.reportedCapacityMah) || null;
|
||||||
|
const batteryKey = registryEntry?.batteryKey
|
||||||
|
|| sessions[0]?.batteryKey
|
||||||
|
|| `unregistered:${rover.roverId}`;
|
||||||
|
const sameBattery = sessions.filter((session) => session.batteryKey === batteryKey);
|
||||||
|
const observations = sameBattery.flatMap((session) => {
|
||||||
|
if (session.kind !== 'discharging' || !referenceMah) return [];
|
||||||
|
const chargeDropMah = Number(session.startChargeMah) - Number(session.endChargeMah);
|
||||||
|
const dischargedMah = Number(session.dischargedMah);
|
||||||
|
if (!Number.isFinite(chargeDropMah) || chargeDropMah < 100 || !Number.isFinite(dischargedMah) || dischargedMah <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const depthPercent = chargeDropMah / referenceMah * 100;
|
||||||
|
/*
|
||||||
|
Packet 25 provides the changing charge position while signed current
|
||||||
|
supplies an independent coulomb count. Extrapolating each partial slice
|
||||||
|
produces a capacity observation without requiring a full-to-empty run.
|
||||||
|
Depth is retained so callers can see exactly how much evidence supports
|
||||||
|
the estimate.
|
||||||
|
*/
|
||||||
|
return [{
|
||||||
|
startedAt: session.startedAt,
|
||||||
|
endedAt: session.endedAt,
|
||||||
|
depthPercent,
|
||||||
|
estimatedUsableMah: dischargedMah / (chargeDropMah / referenceMah),
|
||||||
|
gapCount: Number(session.gapCount) || 0,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
const cleanObservations = observations.filter((observation) => observation.gapCount <= 1);
|
||||||
|
const measuredUsableMah = median(cleanObservations.map((observation) => observation.estimatedUsableMah));
|
||||||
|
const observedChargeHighMah = maximum(rover.minutes, 'maxChargeMah');
|
||||||
|
const observedChargeLowMah = minimum(rover.minutes, 'minChargeMah');
|
||||||
|
const observedUsableFloorMah = observedChargeHighMah != null && observedChargeLowMah != null
|
||||||
|
? Math.max(0, observedChargeHighMah - observedChargeLowMah)
|
||||||
|
: null;
|
||||||
|
const averageDepthPercent = cleanObservations.length
|
||||||
|
? sum(cleanObservations, 'depthPercent') / cleanObservations.length
|
||||||
|
: 0;
|
||||||
|
const baselineMah = Number(registryEntry?.healthyBaselineMah) || referenceMah;
|
||||||
|
const capacityRetentionPercent = measuredUsableMah && baselineMah
|
||||||
|
? measuredUsableMah / baselineMah * 100
|
||||||
|
: null;
|
||||||
|
const nominalVoltageMv = rover.averageVoltageMv;
|
||||||
|
|
||||||
|
return {
|
||||||
|
batteryKey,
|
||||||
|
referenceMah,
|
||||||
|
baselineMah,
|
||||||
|
measuredUsableMah,
|
||||||
|
measuredUsableWh: measuredUsableMah && nominalVoltageMv
|
||||||
|
? measuredUsableMah * nominalVoltageMv / 1e6
|
||||||
|
: null,
|
||||||
|
capacityRetentionPercent,
|
||||||
|
observedUsableFloorMah,
|
||||||
|
observedChargeHighMah,
|
||||||
|
observedChargeLowMah,
|
||||||
|
observationCount: cleanObservations.length,
|
||||||
|
averageObservationDepthPercent: averageDepthPercent,
|
||||||
|
confidence: confidenceForObservationCount(cleanObservations.length, averageDepthPercent),
|
||||||
|
confidenceReason: cleanObservations.length
|
||||||
|
? `${cleanObservations.length} partial current/charge observations averaging ${averageDepthPercent.toFixed(1)}% depth`
|
||||||
|
: 'collecting partial discharge evidence',
|
||||||
|
dischargedThroughputMah: sum(sameBattery, 'dischargedMah'),
|
||||||
|
latestObservationAt: cleanObservations.reduce(
|
||||||
|
(latest, observation) => Math.max(latest, Number(observation.endedAt) || Number(observation.startedAt) || 0),
|
||||||
|
0,
|
||||||
|
) || null,
|
||||||
|
observations: cleanObservations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByRover({ minutes, sessions, roster, batteryRegistry }) {
|
||||||
|
const rosterById = new Map(roster.map((rover) => [String(rover.id), rover]));
|
||||||
|
const minuteGroups = new Map();
|
||||||
|
minutes.forEach((minute) => {
|
||||||
|
const roverId = String(minute.roverId);
|
||||||
|
if (!minuteGroups.has(roverId)) minuteGroups.set(roverId, []);
|
||||||
|
minuteGroups.get(roverId).push(minute);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(new Set([...rosterById.keys(), ...minuteGroups.keys()])).map((roverId) => {
|
||||||
|
const rows = minuteGroups.get(roverId) || [];
|
||||||
|
const distanceMm = sum(rows, 'distanceMm');
|
||||||
|
const dischargedWh = sum(rows, 'dischargedWh');
|
||||||
|
const movingDischargedWh = sum(rows, 'movingDischargedWh');
|
||||||
|
const movingMs = sum(rows, 'movingMs');
|
||||||
|
const latest = rows[rows.length - 1] || null;
|
||||||
|
const base = {
|
||||||
|
roverId,
|
||||||
|
name: rosterById.get(roverId)?.name || roverId,
|
||||||
|
color: rosterById.get(roverId)?.color || null,
|
||||||
|
online: Boolean(rosterById.get(roverId)),
|
||||||
|
minutes: rows,
|
||||||
|
sampleCount: sum(rows, 'sampleCount'),
|
||||||
|
coverageMs: sum(rows, 'coverageMs'),
|
||||||
|
gapCount: sum(rows, 'gapCount'),
|
||||||
|
distanceMm,
|
||||||
|
movingMs,
|
||||||
|
averageSpeedMmPerSecond: movingMs ? distanceMm / (movingMs / 1000) : null,
|
||||||
|
maximumSpeedMmPerSecond: maximum(rows, 'maximumSpeedMmPerSecond'),
|
||||||
|
chargedMah: sum(rows, 'chargedMah'),
|
||||||
|
dischargedMah: sum(rows, 'dischargedMah'),
|
||||||
|
chargedWh: sum(rows, 'chargedWh'),
|
||||||
|
dischargedWh,
|
||||||
|
movingDischargedWh,
|
||||||
|
stationaryDischargedWh: sum(rows, 'stationaryDischargedWh'),
|
||||||
|
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||||
|
? dischargedWh / (distanceMm / 1e6)
|
||||||
|
: null,
|
||||||
|
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||||
|
? movingDischargedWh / (distanceMm / 1e6)
|
||||||
|
: null,
|
||||||
|
efficiencyDistanceRequiredMm: Math.max(0, MINIMUM_EFFICIENCY_DISTANCE_MM - distanceMm),
|
||||||
|
averageVoltageMv: weightedAverage(rows, 'avgVoltageMv'),
|
||||||
|
averageCurrentMa: weightedAverage(rows, 'avgCurrentMa'),
|
||||||
|
minimumVoltageMv: minimum(rows, 'minVoltageMv'),
|
||||||
|
maximumVoltageMv: maximum(rows, 'maxVoltageMv'),
|
||||||
|
averageTemperatureC: weightedAverage(rows, 'avgTemperatureC'),
|
||||||
|
minimumTemperatureC: minimum(rows, 'minTemperatureC'),
|
||||||
|
maximumTemperatureC: maximum(rows, 'maxTemperatureC'),
|
||||||
|
latestChargeMah: latest?.lastChargeMah ?? null,
|
||||||
|
reportedCapacityMah: latest?.reportedCapacityMah ?? null,
|
||||||
|
lastSampleAt: latest ? latest.bucketTs + 60000 : null,
|
||||||
|
};
|
||||||
|
const registryEntry = batteryRegistry.find((battery) =>
|
||||||
|
String(battery.roverId) === roverId && battery.retiredAt == null,
|
||||||
|
);
|
||||||
|
base.batteryHealth = buildBatteryHealth({
|
||||||
|
rover: base,
|
||||||
|
sessions: sessions.filter((session) => String(session.roverId) === roverId),
|
||||||
|
registryEntry,
|
||||||
|
});
|
||||||
|
delete base.minutes;
|
||||||
|
return base;
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttention(roverRows, now) {
|
||||||
|
const attention = [];
|
||||||
|
roverRows.forEach((rover) => {
|
||||||
|
if (!rover.sampleCount) {
|
||||||
|
attention.push({
|
||||||
|
key: `telemetry:${rover.roverId}`,
|
||||||
|
roverId: rover.roverId,
|
||||||
|
severity: 'notice',
|
||||||
|
title: rover.online ? 'Battery metrics unavailable in this range' : 'Rover was not observed in this range',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rover.maximumTemperatureC >= 45) {
|
||||||
|
attention.push({
|
||||||
|
key: `temperature:${rover.roverId}`,
|
||||||
|
roverId: rover.roverId,
|
||||||
|
severity: rover.maximumTemperatureC >= 50 ? 'critical' : 'warning',
|
||||||
|
title: `Battery reached ${rover.maximumTemperatureC} °C`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rover.batteryHealth.capacityRetentionPercent != null
|
||||||
|
&& rover.batteryHealth.confidence !== 'low'
|
||||||
|
&& rover.batteryHealth.capacityRetentionPercent < 80) {
|
||||||
|
attention.push({
|
||||||
|
key: `capacity:${rover.roverId}`,
|
||||||
|
roverId: rover.roverId,
|
||||||
|
severity: rover.batteryHealth.capacityRetentionPercent < 65 ? 'critical' : 'warning',
|
||||||
|
title: `Estimated usable capacity is ${rover.batteryHealth.capacityRetentionPercent.toFixed(1)}% of baseline`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rover.online && rover.lastSampleAt && now - rover.lastSampleAt > 5 * 60 * 1000) {
|
||||||
|
attention.push({
|
||||||
|
key: `stale:${rover.roverId}`,
|
||||||
|
roverId: rover.roverId,
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Battery metrics are stale',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rank = { critical: 0, warning: 1, notice: 2 };
|
||||||
|
return attention.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReportBuilder({ storage, collector, roverManager }) {
|
||||||
|
function build({ since, until, roverIds, includeEvents = false, eventLimit = 500 }) {
|
||||||
|
const visibleRoster = Array.from(roverManager.rovers?.values?.() || []).map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name || record.id,
|
||||||
|
color: record.color || record.meta?.color || null,
|
||||||
|
}));
|
||||||
|
const requestedIds = Array.isArray(roverIds) ? roverIds.map(String) : null;
|
||||||
|
const roster = requestedIds
|
||||||
|
? visibleRoster.filter((rover) => requestedIds.includes(String(rover.id)))
|
||||||
|
: visibleRoster;
|
||||||
|
const effectiveIds = requestedIds || roster.map((rover) => String(rover.id));
|
||||||
|
const minutes = storage.listMinutes({ since, until, roverIds: effectiveIds });
|
||||||
|
const batterySessions = storage.listBatterySessions({ since, until, roverIds: effectiveIds, limit: 2000 });
|
||||||
|
const batteryRegistry = storage.listBatteries(effectiveIds);
|
||||||
|
const roverRows = groupByRover({ minutes, sessions: batterySessions, roster, batteryRegistry });
|
||||||
|
const attention = buildAttention(roverRows, Date.now());
|
||||||
|
const distanceMm = sum(roverRows, 'distanceMm');
|
||||||
|
const dischargedWh = sum(roverRows, 'dischargedWh');
|
||||||
|
const movingDischargedWh = sum(roverRows, 'movingDischargedWh');
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
range: { since, until },
|
||||||
|
methodology: {
|
||||||
|
minimumEfficiencyDistanceMm: MINIMUM_EFFICIENCY_DISTANCE_MM,
|
||||||
|
historicalWhAvailable: false,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
roverCount: roverRows.length,
|
||||||
|
onlineRoverCount: roverRows.filter((rover) => rover.online).length,
|
||||||
|
distanceMm,
|
||||||
|
movingMs: sum(roverRows, 'movingMs'),
|
||||||
|
chargedWh: sum(roverRows, 'chargedWh'),
|
||||||
|
dischargedWh,
|
||||||
|
movingDischargedWh,
|
||||||
|
stationaryDischargedWh: sum(roverRows, 'stationaryDischargedWh'),
|
||||||
|
overallWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||||
|
? dischargedWh / (distanceMm / 1e6)
|
||||||
|
: null,
|
||||||
|
movingWhPerKm: distanceMm >= MINIMUM_EFFICIENCY_DISTANCE_MM
|
||||||
|
? movingDischargedWh / (distanceMm / 1e6)
|
||||||
|
: null,
|
||||||
|
attentionCount: attention.filter((item) => item.severity !== 'notice').length,
|
||||||
|
},
|
||||||
|
rovers: roverRows,
|
||||||
|
attention,
|
||||||
|
batteryRegistry,
|
||||||
|
dailyReportHistory: storage.listDailyReports(365),
|
||||||
|
// Events remain available only for explicit advanced/debug consumers.
|
||||||
|
// Neither the normal UI nor Discord requests them.
|
||||||
|
events: includeEvents
|
||||||
|
? storage.listEvents({ since, until, roverIds: effectiveIds, limit: eventLimit })
|
||||||
|
: [],
|
||||||
|
diagnostics: {
|
||||||
|
collector: collector.getDiagnostics(),
|
||||||
|
storage: storage.getDiagnostics(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { build };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MINIMUM_EFFICIENCY_DISTANCE_MM,
|
||||||
|
createReportBuilder,
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Fleet Report Socket Gateway
|
||||||
|
// Purpose: Exposes read-only, visibility-filtered fleet history to browser clients.
|
||||||
|
// Scope: Owns query validation and existing private/lockdown access boundaries; it performs no collection or analysis.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
|
|
||||||
|
const MAX_RANGE_MS = 366 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function normalizeRange(payload = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const until = Number.isFinite(Number(payload.until)) ? Number(payload.until) : now;
|
||||||
|
const requestedSince = Number.isFinite(Number(payload.since))
|
||||||
|
? Number(payload.since)
|
||||||
|
: until - 24 * 60 * 60 * 1000;
|
||||||
|
const since = Math.max(0, Math.max(requestedSince, until - MAX_RANGE_MS));
|
||||||
|
return { since, until: Math.max(since + 1, until) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSocketGateway({ roverManager, reportBuilder, storage, collector, logger }) {
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('fleetReports:get', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
const { since, until } = normalizeRange(payload);
|
||||||
|
// getRosterForSocket is the canonical live private-rover visibility
|
||||||
|
// resolver. Historical queries use precisely those currently visible
|
||||||
|
// rover IDs so fleet totals cannot indirectly disclose a private rover.
|
||||||
|
const visibleRoverIds = roverManager.getRosterForSocket(socket).map((rover) => String(rover.id));
|
||||||
|
const requestedIds = Array.isArray(payload.roverIds)
|
||||||
|
? payload.roverIds.map(String).filter((id) => visibleRoverIds.includes(id))
|
||||||
|
: visibleRoverIds;
|
||||||
|
const report = reportBuilder.build({
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
roverIds: requestedIds,
|
||||||
|
includeEvents: payload.includeEvents !== false,
|
||||||
|
eventLimit: payload.eventLimit,
|
||||||
|
});
|
||||||
|
// Lockdown-only events are deliberately removed after query assembly.
|
||||||
|
// They are global rather than rover-scoped, so rover filtering alone is
|
||||||
|
// insufficient to preserve the pre-existing lockdown privacy boundary.
|
||||||
|
if (!isLockdownAdmin(socket)) {
|
||||||
|
report.events = report.events.filter((event) => event.visibility !== 'lockdown');
|
||||||
|
report.totals.eventCountReturned = report.events.length;
|
||||||
|
}
|
||||||
|
if (payload.compact === true) {
|
||||||
|
// The Activities card now consumes the same all-rovers metric rows
|
||||||
|
// as fullscreen. The builder no longer attaches minute/session
|
||||||
|
// evidence by default, so compacting only removes archival metadata.
|
||||||
|
report.events = [];
|
||||||
|
report.dailyReportHistory = [];
|
||||||
|
}
|
||||||
|
cb({ ok: true, report });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Fleet report query failed', { socketId: socket.id, error: err.message });
|
||||||
|
cb({ error: 'Fleet report query failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('fleetReports:replaceBattery', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(socket)) throw new Error('Admin access required');
|
||||||
|
const roverId = String(payload.roverId || '').trim();
|
||||||
|
if (!roverId || !roverManager.rovers.has(roverId)) throw new Error('Known online rover required');
|
||||||
|
const ratedCapacityMah = Number(payload.ratedCapacityMah);
|
||||||
|
if (!Number.isFinite(ratedCapacityMah) || ratedCapacityMah <= 0 || ratedCapacityMah > 65535) {
|
||||||
|
throw new Error('Rated capacity must be between 1 and 65535 mAh');
|
||||||
|
}
|
||||||
|
const installedAt = Number.isFinite(Number(payload.installedAt)) ? Number(payload.installedAt) : Date.now();
|
||||||
|
const entry = storage.replaceBattery({
|
||||||
|
roverId,
|
||||||
|
batteryKey: `battery:${roverId}:${installedAt}:${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
chemistry: String(payload.chemistry || '').trim() || null,
|
||||||
|
ratedCapacityMah: Math.round(ratedCapacityMah),
|
||||||
|
installedAt,
|
||||||
|
notes: String(payload.notes || '').trim() || null,
|
||||||
|
});
|
||||||
|
if (!entry) throw new Error('Battery registry write failed');
|
||||||
|
collector.refreshBatteryIdentity(roverId);
|
||||||
|
collector.collectEvent({
|
||||||
|
source: 'fleetReportService',
|
||||||
|
type: 'battery.replaced',
|
||||||
|
payload: { roverId, battery: entry },
|
||||||
|
});
|
||||||
|
cb({ ok: true, battery: entry });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Fleet battery replacement rejected', { socketId: socket.id, error: err.message });
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerSocketGateway,
|
||||||
|
};
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
// Fleet Report Storage
|
||||||
|
// Purpose: Owns the reporting database, schema, bounded writes, and read queries.
|
||||||
|
// Scope: Keeps SQLite details out of telemetry collection, analysis, UI transport, and Discord delivery.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
|
||||||
|
const DB_PATH = resolveDataPath('fleet-reports.sqlite');
|
||||||
|
|
||||||
|
function safeJson(value) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value ?? null);
|
||||||
|
} catch (_err) {
|
||||||
|
// An unusual circular payload must not break the event subscriber. The
|
||||||
|
// placeholder still records that an event occurred and explains why its
|
||||||
|
// supporting payload is unavailable.
|
||||||
|
return JSON.stringify({ serializationError: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, fallback = null) {
|
||||||
|
try {
|
||||||
|
return value == null ? fallback : JSON.parse(value);
|
||||||
|
} catch (_err) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStorage({ logger }) {
|
||||||
|
let db = null;
|
||||||
|
let statements = null;
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (db) return true;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('synchronous = NORMAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
rover_id TEXT,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'global',
|
||||||
|
severity TEXT NOT NULL DEFAULT 'informational',
|
||||||
|
correlation_id TEXT,
|
||||||
|
payload_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_events_ts ON fleet_events(ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_events_rover_ts ON fleet_events(rover_id, ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_events_type_ts ON fleet_events(type, ts DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_minute_samples (
|
||||||
|
rover_id TEXT NOT NULL,
|
||||||
|
bucket_ts INTEGER NOT NULL,
|
||||||
|
sample_count INTEGER NOT NULL,
|
||||||
|
coverage_ms INTEGER NOT NULL,
|
||||||
|
gap_count INTEGER NOT NULL,
|
||||||
|
charged_mah REAL NOT NULL,
|
||||||
|
discharged_mah REAL NOT NULL,
|
||||||
|
charged_wh REAL NOT NULL DEFAULT 0,
|
||||||
|
discharged_wh REAL NOT NULL DEFAULT 0,
|
||||||
|
moving_discharged_wh REAL NOT NULL DEFAULT 0,
|
||||||
|
stationary_discharged_wh REAL NOT NULL DEFAULT 0,
|
||||||
|
moving_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
maximum_speed_mm_per_second REAL,
|
||||||
|
min_voltage_mv INTEGER,
|
||||||
|
max_voltage_mv INTEGER,
|
||||||
|
avg_voltage_mv REAL,
|
||||||
|
min_current_ma INTEGER,
|
||||||
|
max_current_ma INTEGER,
|
||||||
|
avg_current_ma REAL,
|
||||||
|
min_temperature_c INTEGER,
|
||||||
|
max_temperature_c INTEGER,
|
||||||
|
avg_temperature_c REAL,
|
||||||
|
min_charge_mah INTEGER,
|
||||||
|
max_charge_mah INTEGER,
|
||||||
|
last_charge_mah INTEGER,
|
||||||
|
reported_capacity_mah INTEGER,
|
||||||
|
docked_samples INTEGER NOT NULL,
|
||||||
|
charging_samples INTEGER NOT NULL,
|
||||||
|
command_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
drive_command_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rejected_command_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
distance_mm REAL NOT NULL DEFAULT 0,
|
||||||
|
bump_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cliff_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wheel_drop_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
virtual_wall_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
overcurrent_episode_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (rover_id, bucket_ts)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_minutes_ts ON fleet_minute_samples(bucket_ts DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_battery_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rover_id TEXT NOT NULL,
|
||||||
|
battery_key TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
started_at INTEGER NOT NULL,
|
||||||
|
ended_at INTEGER,
|
||||||
|
start_charge_mah INTEGER,
|
||||||
|
end_charge_mah INTEGER,
|
||||||
|
charged_mah REAL NOT NULL DEFAULT 0,
|
||||||
|
discharged_mah REAL NOT NULL DEFAULT 0,
|
||||||
|
min_voltage_mv INTEGER,
|
||||||
|
max_voltage_mv INTEGER,
|
||||||
|
min_temperature_c INTEGER,
|
||||||
|
max_temperature_c INTEGER,
|
||||||
|
sample_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gap_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
confidence TEXT NOT NULL DEFAULT 'low',
|
||||||
|
qualification_reason TEXT,
|
||||||
|
details_json TEXT NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_battery_sessions_rover_time ON fleet_battery_sessions(rover_id, started_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_batteries (
|
||||||
|
battery_key TEXT PRIMARY KEY,
|
||||||
|
rover_id TEXT NOT NULL,
|
||||||
|
chemistry TEXT,
|
||||||
|
rated_capacity_mah INTEGER,
|
||||||
|
installed_at INTEGER,
|
||||||
|
retired_at INTEGER,
|
||||||
|
healthy_baseline_mah REAL,
|
||||||
|
notes TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fleet_batteries_rover ON fleet_batteries(rover_id, installed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fleet_daily_reports (
|
||||||
|
report_date TEXT PRIMARY KEY,
|
||||||
|
generated_at INTEGER NOT NULL,
|
||||||
|
report_json TEXT NOT NULL,
|
||||||
|
discord_delivered_at INTEGER,
|
||||||
|
discord_error TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// SQLite's CREATE TABLE IF NOT EXISTS does not add columns to an older
|
||||||
|
// reporting database. These narrow additive migrations keep development
|
||||||
|
// databases usable as collection coverage expands without coupling this
|
||||||
|
// optional feature to the identity database's migration history.
|
||||||
|
const minuteColumns = new Set(db.prepare('PRAGMA table_info(fleet_minute_samples)').all().map((column) => column.name));
|
||||||
|
[
|
||||||
|
['command_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['drive_command_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['rejected_command_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['distance_mm', 'REAL NOT NULL DEFAULT 0'],
|
||||||
|
['bump_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['cliff_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['wheel_drop_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['virtual_wall_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['overcurrent_episode_count', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['charged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||||
|
['discharged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||||
|
['moving_discharged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||||
|
['stationary_discharged_wh', 'REAL NOT NULL DEFAULT 0'],
|
||||||
|
['moving_ms', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['maximum_speed_mm_per_second', 'REAL'],
|
||||||
|
].forEach(([name, definition]) => {
|
||||||
|
if (!minuteColumns.has(name)) db.exec(`ALTER TABLE fleet_minute_samples ADD COLUMN ${name} ${definition}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
statements = {
|
||||||
|
insertEvent: db.prepare(`
|
||||||
|
INSERT INTO fleet_events (ts, source, type, rover_id, visibility, severity, correlation_id, payload_json)
|
||||||
|
VALUES (@ts, @source, @type, @roverId, @visibility, @severity, @correlationId, @payloadJson)
|
||||||
|
`),
|
||||||
|
upsertMinute: db.prepare(`
|
||||||
|
INSERT INTO fleet_minute_samples (
|
||||||
|
rover_id, bucket_ts, sample_count, coverage_ms, gap_count,
|
||||||
|
charged_mah, discharged_mah, min_voltage_mv, max_voltage_mv, avg_voltage_mv,
|
||||||
|
charged_wh, discharged_wh, moving_discharged_wh,
|
||||||
|
stationary_discharged_wh, moving_ms, maximum_speed_mm_per_second,
|
||||||
|
min_current_ma, max_current_ma, avg_current_ma, min_temperature_c,
|
||||||
|
max_temperature_c, avg_temperature_c, min_charge_mah, max_charge_mah,
|
||||||
|
last_charge_mah, reported_capacity_mah, docked_samples, charging_samples,
|
||||||
|
command_count, drive_command_count, rejected_command_count,
|
||||||
|
distance_mm, bump_count, cliff_count, wheel_drop_count,
|
||||||
|
virtual_wall_count, overcurrent_episode_count
|
||||||
|
) VALUES (
|
||||||
|
@roverId, @bucketTs, @sampleCount, @coverageMs, @gapCount,
|
||||||
|
@chargedMah, @dischargedMah, @minVoltageMv, @maxVoltageMv, @avgVoltageMv,
|
||||||
|
@chargedWh, @dischargedWh, @movingDischargedWh,
|
||||||
|
@stationaryDischargedWh, @movingMs, @maximumSpeedMmPerSecond,
|
||||||
|
@minCurrentMa, @maxCurrentMa, @avgCurrentMa, @minTemperatureC,
|
||||||
|
@maxTemperatureC, @avgTemperatureC, @minChargeMah, @maxChargeMah,
|
||||||
|
@lastChargeMah, @reportedCapacityMah, @dockedSamples, @chargingSamples,
|
||||||
|
@commandCount, @driveCommandCount, @rejectedCommandCount,
|
||||||
|
@distanceMm, @bumpCount, @cliffCount, @wheelDropCount,
|
||||||
|
@virtualWallCount, @overcurrentEpisodeCount
|
||||||
|
)
|
||||||
|
ON CONFLICT(rover_id, bucket_ts) DO UPDATE SET
|
||||||
|
sample_count = excluded.sample_count,
|
||||||
|
coverage_ms = excluded.coverage_ms,
|
||||||
|
gap_count = excluded.gap_count,
|
||||||
|
charged_mah = excluded.charged_mah,
|
||||||
|
discharged_mah = excluded.discharged_mah,
|
||||||
|
charged_wh = excluded.charged_wh,
|
||||||
|
discharged_wh = excluded.discharged_wh,
|
||||||
|
moving_discharged_wh = excluded.moving_discharged_wh,
|
||||||
|
stationary_discharged_wh = excluded.stationary_discharged_wh,
|
||||||
|
moving_ms = excluded.moving_ms,
|
||||||
|
maximum_speed_mm_per_second = excluded.maximum_speed_mm_per_second,
|
||||||
|
min_voltage_mv = excluded.min_voltage_mv,
|
||||||
|
max_voltage_mv = excluded.max_voltage_mv,
|
||||||
|
avg_voltage_mv = excluded.avg_voltage_mv,
|
||||||
|
min_current_ma = excluded.min_current_ma,
|
||||||
|
max_current_ma = excluded.max_current_ma,
|
||||||
|
avg_current_ma = excluded.avg_current_ma,
|
||||||
|
min_temperature_c = excluded.min_temperature_c,
|
||||||
|
max_temperature_c = excluded.max_temperature_c,
|
||||||
|
avg_temperature_c = excluded.avg_temperature_c,
|
||||||
|
min_charge_mah = excluded.min_charge_mah,
|
||||||
|
max_charge_mah = excluded.max_charge_mah,
|
||||||
|
last_charge_mah = excluded.last_charge_mah,
|
||||||
|
reported_capacity_mah = excluded.reported_capacity_mah,
|
||||||
|
docked_samples = excluded.docked_samples,
|
||||||
|
charging_samples = excluded.charging_samples,
|
||||||
|
command_count = excluded.command_count,
|
||||||
|
drive_command_count = excluded.drive_command_count,
|
||||||
|
rejected_command_count = excluded.rejected_command_count
|
||||||
|
,distance_mm = excluded.distance_mm
|
||||||
|
,bump_count = excluded.bump_count
|
||||||
|
,cliff_count = excluded.cliff_count
|
||||||
|
,wheel_drop_count = excluded.wheel_drop_count
|
||||||
|
,virtual_wall_count = excluded.virtual_wall_count
|
||||||
|
,overcurrent_episode_count = excluded.overcurrent_episode_count
|
||||||
|
`),
|
||||||
|
insertSession: db.prepare(`
|
||||||
|
INSERT INTO fleet_battery_sessions (
|
||||||
|
rover_id, battery_key, kind, started_at, ended_at, start_charge_mah,
|
||||||
|
end_charge_mah, charged_mah, discharged_mah, min_voltage_mv,
|
||||||
|
max_voltage_mv, min_temperature_c, max_temperature_c, sample_count,
|
||||||
|
gap_count, status, confidence, qualification_reason, details_json
|
||||||
|
) VALUES (
|
||||||
|
@roverId, @batteryKey, @kind, @startedAt, @endedAt, @startChargeMah,
|
||||||
|
@endChargeMah, @chargedMah, @dischargedMah, @minVoltageMv,
|
||||||
|
@maxVoltageMv, @minTemperatureC, @maxTemperatureC, @sampleCount,
|
||||||
|
@gapCount, @status, @confidence, @qualificationReason, @detailsJson
|
||||||
|
)
|
||||||
|
`),
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to open fleet report database; reporting will remain fail-open', {
|
||||||
|
path: DB_PATH,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
if (db) {
|
||||||
|
try { db.close(); } catch (_closeErr) { /* Best-effort cleanup after failed initialization. */ }
|
||||||
|
}
|
||||||
|
db = null;
|
||||||
|
statements = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSafely(label, operation, fallback = null) {
|
||||||
|
if (!open()) return fallback;
|
||||||
|
try {
|
||||||
|
return operation();
|
||||||
|
} catch (err) {
|
||||||
|
// Reporting is observer-only. A failed write/query is visible in logs but
|
||||||
|
// is never allowed to propagate into a rover sensor or command callback.
|
||||||
|
logger.warn(`Fleet report storage ${label} failed`, { error: err.message });
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEvent(event) {
|
||||||
|
return runSafely('event write', () => statements.insertEvent.run({
|
||||||
|
ts: event.ts,
|
||||||
|
source: event.source,
|
||||||
|
type: event.type,
|
||||||
|
roverId: event.roverId || null,
|
||||||
|
visibility: event.visibility || 'global',
|
||||||
|
severity: event.severity || 'informational',
|
||||||
|
correlationId: event.correlationId || null,
|
||||||
|
payloadJson: safeJson(event.payload),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMinute(sample) {
|
||||||
|
return runSafely('minute write', () => statements.upsertMinute.run(sample));
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBatterySession(session) {
|
||||||
|
return runSafely('battery session write', () => statements.insertSession.run({
|
||||||
|
...session,
|
||||||
|
detailsJson: safeJson(session.details || {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listEvents({ since, until, roverIds, limit = 500, offset = 0, type = null }) {
|
||||||
|
return runSafely('event query', () => {
|
||||||
|
const clauses = ['ts >= ?', 'ts < ?'];
|
||||||
|
const params = [since, until];
|
||||||
|
if (type) {
|
||||||
|
clauses.push('type = ?');
|
||||||
|
params.push(type);
|
||||||
|
}
|
||||||
|
if (Array.isArray(roverIds)) {
|
||||||
|
if (roverIds.length === 0) clauses.push('rover_id IS NULL');
|
||||||
|
else {
|
||||||
|
clauses.push(`(rover_id IS NULL OR rover_id IN (${roverIds.map(() => '?').join(',')}))`);
|
||||||
|
params.push(...roverIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.push(Math.max(1, Math.min(2000, Number(limit) || 500)), Math.max(0, Number(offset) || 0));
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, ts, source, type, rover_id AS roverId, visibility, severity,
|
||||||
|
correlation_id AS correlationId, payload_json AS payloadJson
|
||||||
|
FROM fleet_events
|
||||||
|
WHERE ${clauses.join(' AND ')}
|
||||||
|
ORDER BY ts DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...params);
|
||||||
|
return rows.map(({ payloadJson, ...row }) => ({ ...row, payload: parseJson(payloadJson, {}) }));
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMinutes({ since, until, roverIds }) {
|
||||||
|
return runSafely('minute query', () => {
|
||||||
|
const clauses = ['bucket_ts >= ?', 'bucket_ts < ?'];
|
||||||
|
const params = [since, until];
|
||||||
|
if (Array.isArray(roverIds)) {
|
||||||
|
if (roverIds.length === 0) return [];
|
||||||
|
clauses.push(`rover_id IN (${roverIds.map(() => '?').join(',')})`);
|
||||||
|
params.push(...roverIds);
|
||||||
|
}
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT rover_id AS roverId, bucket_ts AS bucketTs, sample_count AS sampleCount,
|
||||||
|
coverage_ms AS coverageMs, gap_count AS gapCount, charged_mah AS chargedMah,
|
||||||
|
discharged_mah AS dischargedMah, min_voltage_mv AS minVoltageMv,
|
||||||
|
charged_wh AS chargedWh, discharged_wh AS dischargedWh,
|
||||||
|
moving_discharged_wh AS movingDischargedWh,
|
||||||
|
stationary_discharged_wh AS stationaryDischargedWh,
|
||||||
|
moving_ms AS movingMs,
|
||||||
|
maximum_speed_mm_per_second AS maximumSpeedMmPerSecond,
|
||||||
|
max_voltage_mv AS maxVoltageMv, avg_voltage_mv AS avgVoltageMv,
|
||||||
|
min_current_ma AS minCurrentMa, max_current_ma AS maxCurrentMa,
|
||||||
|
avg_current_ma AS avgCurrentMa, min_temperature_c AS minTemperatureC,
|
||||||
|
max_temperature_c AS maxTemperatureC, avg_temperature_c AS avgTemperatureC,
|
||||||
|
min_charge_mah AS minChargeMah, max_charge_mah AS maxChargeMah,
|
||||||
|
last_charge_mah AS lastChargeMah, reported_capacity_mah AS reportedCapacityMah,
|
||||||
|
docked_samples AS dockedSamples, charging_samples AS chargingSamples,
|
||||||
|
command_count AS commandCount, drive_command_count AS driveCommandCount,
|
||||||
|
rejected_command_count AS rejectedCommandCount
|
||||||
|
,distance_mm AS distanceMm, bump_count AS bumpCount,
|
||||||
|
cliff_count AS cliffCount, wheel_drop_count AS wheelDropCount,
|
||||||
|
virtual_wall_count AS virtualWallCount,
|
||||||
|
overcurrent_episode_count AS overcurrentEpisodeCount
|
||||||
|
FROM fleet_minute_samples
|
||||||
|
WHERE ${clauses.join(' AND ')}
|
||||||
|
ORDER BY bucket_ts ASC, rover_id ASC
|
||||||
|
`).all(...params);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBatterySessions({ since, until, roverIds, limit = 500 }) {
|
||||||
|
return runSafely('battery session query', () => {
|
||||||
|
if (Array.isArray(roverIds) && roverIds.length === 0) return [];
|
||||||
|
const roverClause = Array.isArray(roverIds)
|
||||||
|
? `AND rover_id IN (${roverIds.map(() => '?').join(',')})`
|
||||||
|
: '';
|
||||||
|
const params = [since, until, ...(roverIds || []), Math.max(1, Math.min(2000, Number(limit) || 500))];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, rover_id AS roverId, battery_key AS batteryKey, kind,
|
||||||
|
started_at AS startedAt, ended_at AS endedAt,
|
||||||
|
start_charge_mah AS startChargeMah, end_charge_mah AS endChargeMah,
|
||||||
|
charged_mah AS chargedMah, discharged_mah AS dischargedMah,
|
||||||
|
min_voltage_mv AS minVoltageMv, max_voltage_mv AS maxVoltageMv,
|
||||||
|
min_temperature_c AS minTemperatureC, max_temperature_c AS maxTemperatureC,
|
||||||
|
sample_count AS sampleCount, gap_count AS gapCount, status,
|
||||||
|
confidence, qualification_reason AS qualificationReason,
|
||||||
|
details_json AS detailsJson
|
||||||
|
FROM fleet_battery_sessions
|
||||||
|
WHERE started_at < ? AND COALESCE(ended_at, started_at) >= ? ${roverClause}
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(until, since, ...(roverIds || []), params[params.length - 1]).map(({ detailsJson, ...row }) => ({
|
||||||
|
...row,
|
||||||
|
details: parseJson(detailsJson, {}),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune({ detailedBefore, minuteBefore }) {
|
||||||
|
return runSafely('retention prune', () => db.transaction(() => {
|
||||||
|
const events = db.prepare('DELETE FROM fleet_events WHERE ts < ?').run(detailedBefore).changes;
|
||||||
|
const minutes = db.prepare('DELETE FROM fleet_minute_samples WHERE bucket_ts < ?').run(minuteBefore).changes;
|
||||||
|
return { events, minutes };
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDailyReport(reportDate) {
|
||||||
|
return runSafely('daily report query', () => {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT report_date AS reportDate, generated_at AS generatedAt,
|
||||||
|
report_json AS reportJson, discord_delivered_at AS discordDeliveredAt,
|
||||||
|
discord_error AS discordError
|
||||||
|
FROM fleet_daily_reports WHERE report_date = ?
|
||||||
|
`).get(reportDate);
|
||||||
|
if (!row) return null;
|
||||||
|
const { reportJson, ...metadata } = row;
|
||||||
|
return { ...metadata, report: parseJson(reportJson, null) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBatteries(roverIds = null) {
|
||||||
|
return runSafely('battery registry query', () => {
|
||||||
|
if (Array.isArray(roverIds) && roverIds.length === 0) return [];
|
||||||
|
const clause = Array.isArray(roverIds)
|
||||||
|
? `WHERE rover_id IN (${roverIds.map(() => '?').join(',')})`
|
||||||
|
: '';
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT battery_key AS batteryKey, rover_id AS roverId, chemistry,
|
||||||
|
rated_capacity_mah AS ratedCapacityMah, installed_at AS installedAt,
|
||||||
|
retired_at AS retiredAt, healthy_baseline_mah AS healthyBaselineMah,
|
||||||
|
notes, updated_at AS updatedAt
|
||||||
|
FROM fleet_batteries ${clause}
|
||||||
|
ORDER BY rover_id ASC, installed_at DESC
|
||||||
|
`).all(...(roverIds || []));
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveBattery(roverId) {
|
||||||
|
return runSafely('active battery query', () => db.prepare(`
|
||||||
|
SELECT battery_key AS batteryKey, rover_id AS roverId, chemistry,
|
||||||
|
rated_capacity_mah AS ratedCapacityMah, installed_at AS installedAt,
|
||||||
|
healthy_baseline_mah AS healthyBaselineMah, notes, updated_at AS updatedAt
|
||||||
|
FROM fleet_batteries
|
||||||
|
WHERE rover_id = ? AND retired_at IS NULL
|
||||||
|
ORDER BY installed_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(String(roverId)) || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBattery(entry) {
|
||||||
|
return runSafely('battery replacement write', () => db.transaction(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare('UPDATE fleet_batteries SET retired_at = ?, updated_at = ? WHERE rover_id = ? AND retired_at IS NULL')
|
||||||
|
.run(entry.installedAt || now, now, entry.roverId);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO fleet_batteries (
|
||||||
|
battery_key, rover_id, chemistry, rated_capacity_mah, installed_at,
|
||||||
|
retired_at, healthy_baseline_mah, notes, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
entry.batteryKey,
|
||||||
|
entry.roverId,
|
||||||
|
entry.chemistry || null,
|
||||||
|
entry.ratedCapacityMah || null,
|
||||||
|
entry.installedAt || now,
|
||||||
|
entry.healthyBaselineMah || null,
|
||||||
|
entry.notes || null,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return getActiveBattery(entry.roverId);
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDailyReport(reportDate, report) {
|
||||||
|
return runSafely('daily report write', () => db.prepare(`
|
||||||
|
INSERT INTO fleet_daily_reports (report_date, generated_at, report_json)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(report_date) DO UPDATE SET
|
||||||
|
generated_at = excluded.generated_at,
|
||||||
|
report_json = excluded.report_json
|
||||||
|
`).run(reportDate, Date.now(), safeJson(report)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDailyReports(limit = 90) {
|
||||||
|
return runSafely('daily report history query', () => db.prepare(`
|
||||||
|
SELECT report_date AS reportDate, generated_at AS generatedAt,
|
||||||
|
discord_delivered_at AS discordDeliveredAt, discord_error AS discordError,
|
||||||
|
length(report_json) AS reportBytes
|
||||||
|
FROM fleet_daily_reports
|
||||||
|
ORDER BY report_date DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(Math.max(1, Math.min(1000, Number(limit) || 90))), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDailyReportDelivery(reportDate, { deliveredAt = null, error = null } = {}) {
|
||||||
|
return runSafely('daily delivery update', () => db.prepare(`
|
||||||
|
UPDATE fleet_daily_reports
|
||||||
|
SET discord_delivered_at = ?, discord_error = ?
|
||||||
|
WHERE report_date = ?
|
||||||
|
`).run(deliveredAt, error, reportDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiagnostics() {
|
||||||
|
return runSafely('diagnostics query', () => ({
|
||||||
|
available: true,
|
||||||
|
path: DB_PATH,
|
||||||
|
bytes: fs.statSync(DB_PATH).size,
|
||||||
|
eventCount: db.prepare('SELECT COUNT(*) AS count FROM fleet_events').get().count,
|
||||||
|
minuteCount: db.prepare('SELECT COUNT(*) AS count FROM fleet_minute_samples').get().count,
|
||||||
|
sessionCount: db.prepare('SELECT COUNT(*) AS count FROM fleet_battery_sessions').get().count,
|
||||||
|
}), { available: false, path: DB_PATH });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
insertEvent,
|
||||||
|
upsertMinute,
|
||||||
|
insertBatterySession,
|
||||||
|
listEvents,
|
||||||
|
listMinutes,
|
||||||
|
listBatterySessions,
|
||||||
|
prune,
|
||||||
|
getDailyReport,
|
||||||
|
saveDailyReport,
|
||||||
|
listDailyReports,
|
||||||
|
markDailyReportDelivery,
|
||||||
|
listBatteries,
|
||||||
|
getActiveBattery,
|
||||||
|
replaceBattery,
|
||||||
|
getDiagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createStorage,
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Green Mode Service
|
||||||
|
// Purpose: Owns the temporary server-wide green visual mode and its tiny light workflow.
|
||||||
|
// Scope: Composes existing Home Assistant operations; it does not add policy to that service.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const logger = require('../../globals/logger').child('greenModeService');
|
||||||
|
const { sendAlert } = require('../alertService');
|
||||||
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const { modeEvents } = require('../modeManager');
|
||||||
|
|
||||||
|
const GREEN_MODE_COLOR = '#00ff00';
|
||||||
|
const greenModeEvents = new EventEmitter();
|
||||||
|
let enabled = false;
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setEnabled(nextValue, options = {}) {
|
||||||
|
const next = Boolean(nextValue);
|
||||||
|
if (enabled === next) return enabled;
|
||||||
|
|
||||||
|
if (next && homeAssistantService.enabled) {
|
||||||
|
/*
|
||||||
|
Lock first because the existing locked-on transition sets lights white.
|
||||||
|
Recoloring RGB lights afterward leaves them green while retaining the
|
||||||
|
established room-control lock, idle protection, and laser safety rules.
|
||||||
|
*/
|
||||||
|
await homeAssistantService.setLightsLockedOn(true, {
|
||||||
|
source: String(options?.source || 'greenMode:enable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const entities = homeAssistantService.getState()?.entities || [];
|
||||||
|
/*
|
||||||
|
RGB-capable lights become the requested solid green. Every other
|
||||||
|
configured room control, including white-only bulbs and switches, is
|
||||||
|
explicitly turned off so the physical room has one unambiguous effect.
|
||||||
|
These remain generic Home Assistant calls; that service does not know
|
||||||
|
that the operations belong to green mode.
|
||||||
|
*/
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
entities.map((entity) => (
|
||||||
|
entity?.supportsColor
|
||||||
|
? homeAssistantService.setLightColor(entity.id, GREEN_MODE_COLOR)
|
||||||
|
: homeAssistantService.setEntityState(entity.id, 'off', {
|
||||||
|
source: 'greenMode:non-rgb-off',
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const failures = results
|
||||||
|
.map((result, index) => ({ result, entityId: entities[index].id }))
|
||||||
|
.filter(({ result }) => result.status === 'rejected')
|
||||||
|
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
logger.warn('Some room controls failed to enter green mode', { failures });
|
||||||
|
}
|
||||||
|
} else if (!next && homeAssistantService.enabled) {
|
||||||
|
// Disabling the visual mode simply releases the lock it created. Bulb
|
||||||
|
// colors remain untouched, matching the existing one-shot light behavior.
|
||||||
|
await homeAssistantService.setLightsLockedOn(false, {
|
||||||
|
source: String(options?.source || 'greenMode:disable'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Home Assistant is deliberately optional here. When it is not configured,
|
||||||
|
skipping the physical-room operations still allows the session theme,
|
||||||
|
CardFrame styling, alerts, commands, and timed reward to work normally.
|
||||||
|
The integration's generic lock state is also left untouched because there
|
||||||
|
are no server-managed room controls to lock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
enabled = next;
|
||||||
|
logger.info('Green mode changed', {
|
||||||
|
enabled,
|
||||||
|
source: options?.source || 'unknown',
|
||||||
|
});
|
||||||
|
// Emit one shared server alert for every completed transition. Automatic
|
||||||
|
// access-mode shutdown uses this same function, so clients also receive the
|
||||||
|
// inactive notice when green mode ends without an explicit chat command.
|
||||||
|
sendAlert({
|
||||||
|
color: GREEN_MODE_COLOR,
|
||||||
|
title: 'Green mode',
|
||||||
|
message: enabled ? 'Green mode is active.' : 'Green mode is inactive.',
|
||||||
|
});
|
||||||
|
greenModeEvents.emit('change', enabled);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
modeEvents.on('change', () => {
|
||||||
|
if (!enabled) return;
|
||||||
|
setEnabled(false, { source: 'modeGateReset' }).catch((err) => {
|
||||||
|
logger.warn('Failed to disable green mode on access-mode change', err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isEnabled,
|
||||||
|
setEnabled,
|
||||||
|
greenModeEvents,
|
||||||
|
};
|
||||||
@@ -78,6 +78,7 @@ module.exports = {
|
|||||||
setLightColor: runtimeEngine.setLightColor,
|
setLightColor: runtimeEngine.setLightColor,
|
||||||
setLightWhite: runtimeEngine.setLightWhite,
|
setLightWhite: runtimeEngine.setLightWhite,
|
||||||
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
|
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
|
||||||
|
setRandomColorScene: runtimeEngine.setRandomColorScene,
|
||||||
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||||
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
|
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
|
||||||
homeAssistantEvents: events,
|
homeAssistantEvents: events,
|
||||||
|
|||||||
@@ -80,6 +80,27 @@ function normalizeRgbColor(color) {
|
|||||||
function createRuntimeEngine(deps) {
|
function createRuntimeEngine(deps) {
|
||||||
const { logger, enabled, haConfig, callHomeAssistantService } = deps;
|
const { logger, enabled, haConfig, callHomeAssistantService } = deps;
|
||||||
|
|
||||||
|
async function turnOnLightAtFullBrightness(entityId, serviceData = {}) {
|
||||||
|
/*
|
||||||
|
Every server-owned interaction that turns on or changes a light must
|
||||||
|
also restore it to full brightness. Home Assistant remembers a bulb's
|
||||||
|
previous brightness, so sending only a color or color temperature can
|
||||||
|
otherwise make a light appear unexpectedly dim even though this service
|
||||||
|
requested an on-state.
|
||||||
|
|
||||||
|
Keeping this rule in one helper makes it apply consistently to ordinary
|
||||||
|
on commands, RGB changes, white-temperature changes, bulk operations,
|
||||||
|
random scenes, and lock-on behavior. brightness_pct is deliberately
|
||||||
|
written after the caller's service data so future call sites cannot
|
||||||
|
accidentally override the service-wide 100 percent requirement.
|
||||||
|
*/
|
||||||
|
await callHomeAssistantService('light', 'turn_on', {
|
||||||
|
entity_id: entityId,
|
||||||
|
...serviceData,
|
||||||
|
brightness_pct: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function emitUpdate(getState) {
|
function emitUpdate(getState) {
|
||||||
events.emit('update', getState());
|
events.emit('update', getState());
|
||||||
}
|
}
|
||||||
@@ -143,7 +164,14 @@ function createRuntimeEngine(deps) {
|
|||||||
const domain = String(meta.domain || (meta.type === 'light' ? 'light' : 'switch')).toLowerCase();
|
const domain = String(meta.domain || (meta.type === 'light' ? 'light' : 'switch')).toLowerCase();
|
||||||
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
|
||||||
const source = String(options?.source || 'unknown');
|
const source = String(options?.source || 'unknown');
|
||||||
await callHomeAssistantService(domain, service, { entity_id: entityId });
|
if (domain === 'light' && service === 'turn_on') {
|
||||||
|
await turnOnLightAtFullBrightness(entityId);
|
||||||
|
} else {
|
||||||
|
// Off commands and non-light domains do not accept a meaningful light
|
||||||
|
// brightness value, so their existing Home Assistant payload stays
|
||||||
|
// intentionally unchanged.
|
||||||
|
await callHomeAssistantService(domain, service, { entity_id: entityId });
|
||||||
|
}
|
||||||
logger.info('Issued Home Assistant command', { entityId, domain, service, source });
|
logger.info('Issued Home Assistant command', { entityId, domain, service, source });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +224,72 @@ function createRuntimeEngine(deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBrightRandomRgbColor() {
|
||||||
|
// A completely random RGB triplet frequently produces colors that are very
|
||||||
|
// dark, gray, or visually indistinguishable from a bulb being off. Choosing
|
||||||
|
// a random hue at full saturation and brightness still gives every bulb a
|
||||||
|
// genuinely random color while keeping the requested room effect vivid.
|
||||||
|
const hueSegment = Math.random() * 6;
|
||||||
|
const segmentIndex = Math.floor(hueSegment);
|
||||||
|
const risingChannel = Math.round((hueSegment - segmentIndex) * 255);
|
||||||
|
const fallingChannel = 255 - risingChannel;
|
||||||
|
|
||||||
|
switch (segmentIndex) {
|
||||||
|
case 0: return [255, risingChannel, 0];
|
||||||
|
case 1: return [fallingChannel, 255, 0];
|
||||||
|
case 2: return [0, 255, risingChannel];
|
||||||
|
case 3: return [0, fallingChannel, 255];
|
||||||
|
case 4: return [risingChannel, 0, 255];
|
||||||
|
default: return [255, 0, fallingChannel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRandomColorScene(options = {}) {
|
||||||
|
const source = String(options?.source || 'homeAssistant:setRandomColorScene');
|
||||||
|
const entities = Array.from(entityConfig.values()).map((meta) => ({
|
||||||
|
meta,
|
||||||
|
state: entityState.get(meta.id) || buildState(meta, null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// RGB capability comes from Home Assistant's live supported_color_modes
|
||||||
|
// snapshot. This avoids a second operator-maintained list and makes newly
|
||||||
|
// replaced bulbs automatically participate once Home Assistant reports
|
||||||
|
// their capabilities. Everything else is turned off, including switches
|
||||||
|
// and white-only lights, exactly matching the scene's requested boundary.
|
||||||
|
const operations = entities.map(({ meta, state }) => {
|
||||||
|
if (state.supportsColor) {
|
||||||
|
return setLightColor(meta.id, createBrightRandomRgbColor());
|
||||||
|
}
|
||||||
|
return setEntityState(meta.id, 'off', { source: `${source}:non-rgb-off` });
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled(operations);
|
||||||
|
const failures = results
|
||||||
|
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
|
||||||
|
.filter(({ result }) => result.status === 'rejected')
|
||||||
|
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
|
||||||
|
const succeeded = results
|
||||||
|
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
|
||||||
|
.filter(({ result }) => result.status === 'fulfilled')
|
||||||
|
.map(({ entityId }) => entityId);
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
logger.warn('Some Home Assistant random color scene updates failed', {
|
||||||
|
total: entities.length,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
total: entities.length,
|
||||||
|
colorLights: entities.filter(({ state }) => state.supportsColor).length,
|
||||||
|
nonColorEntities: entities.filter(({ state }) => !state.supportsColor).length,
|
||||||
|
succeeded,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function setEntityLockedOnWhite(entityId, options = {}) {
|
async function setEntityLockedOnWhite(entityId, options = {}) {
|
||||||
const meta = entityConfig.get(entityId);
|
const meta = entityConfig.get(entityId);
|
||||||
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
|
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
|
||||||
@@ -401,7 +495,7 @@ function createRuntimeEngine(deps) {
|
|||||||
if (!runtime.connection) throw new Error('Home Assistant not connected');
|
if (!runtime.connection) throw new Error('Home Assistant not connected');
|
||||||
|
|
||||||
const normalized = normalizeRgbColor(color);
|
const normalized = normalizeRgbColor(color);
|
||||||
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
|
await turnOnLightAtFullBrightness(entityId, { rgb_color: normalized });
|
||||||
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +509,7 @@ function createRuntimeEngine(deps) {
|
|||||||
const normalizedKelvin = Number.isFinite(nextKelvin)
|
const normalizedKelvin = Number.isFinite(nextKelvin)
|
||||||
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
|
||||||
: DEFAULT_WHITE_KELVIN;
|
: DEFAULT_WHITE_KELVIN;
|
||||||
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: normalizedKelvin });
|
await turnOnLightAtFullBrightness(entityId, { color_temp_kelvin: normalizedKelvin });
|
||||||
logger.info('Issued Home Assistant white command', { entityId, colorTempKelvin: normalizedKelvin });
|
logger.info('Issued Home Assistant white command', { entityId, colorTempKelvin: normalizedKelvin });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +592,7 @@ function createRuntimeEngine(deps) {
|
|||||||
setLightColor,
|
setLightColor,
|
||||||
setLightWhite,
|
setLightWhite,
|
||||||
setAllControllableEntitiesState,
|
setAllControllableEntitiesState,
|
||||||
|
setRandomColorScene,
|
||||||
setAllControllableEntitiesLockedOnWhite,
|
setAllControllableEntitiesLockedOnWhite,
|
||||||
setLightsLockedOn,
|
setLightsLockedOn,
|
||||||
toggleLightsLockedOn,
|
toggleLightsLockedOn,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Home Assistant Runtime Engine Tests
|
||||||
|
// Purpose: Verifies the service-wide full-brightness rule for light commands.
|
||||||
|
// Scope: Exercises injected Home Assistant calls without opening a real connection or starting the server.
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { createRuntimeEngine } = require('./runtimeEngine');
|
||||||
|
const { entityConfig, entityState, runtime } = require('./state');
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
const calls = [];
|
||||||
|
const engine = createRuntimeEngine({
|
||||||
|
enabled: true,
|
||||||
|
haConfig: { whiteKelvin: 4000 },
|
||||||
|
callHomeAssistantService: async (domain, service, serviceData) => {
|
||||||
|
calls.push({ domain, service, serviceData });
|
||||||
|
},
|
||||||
|
// These tests only verify outbound service payloads. A no-op logger keeps
|
||||||
|
// the harness faithful to the runtime dependency contract without adding
|
||||||
|
// unrelated output to the test run.
|
||||||
|
logger: {
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { calls, engine };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('light interactions force full brightness without changing switches or off commands', async (t) => {
|
||||||
|
const { calls, engine } = createHarness();
|
||||||
|
|
||||||
|
/*
|
||||||
|
runtimeEngine uses the shared entity registry populated from configuration
|
||||||
|
in production. Seed the smallest representative registry here and restore
|
||||||
|
the shared state afterward so this focused unit test cannot leak state into
|
||||||
|
other Home Assistant tests added later.
|
||||||
|
*/
|
||||||
|
entityConfig.clear();
|
||||||
|
entityState.clear();
|
||||||
|
entityConfig.set('light.room', { id: 'light.room', type: 'light', domain: 'light' });
|
||||||
|
entityConfig.set('switch.lamp', { id: 'switch.lamp', type: 'switch', domain: 'switch' });
|
||||||
|
runtime.connection = {};
|
||||||
|
t.after(() => {
|
||||||
|
entityConfig.clear();
|
||||||
|
entityState.clear();
|
||||||
|
runtime.connection = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.setEntityState('light.room', 'on');
|
||||||
|
await engine.setLightColor('light.room', [12, 34, 56]);
|
||||||
|
await engine.setLightWhite('light.room', 4500);
|
||||||
|
await engine.setEntityState('light.room', 'off');
|
||||||
|
await engine.setEntityState('switch.lamp', 'on');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_on',
|
||||||
|
serviceData: { entity_id: 'light.room', brightness_pct: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_on',
|
||||||
|
serviceData: { entity_id: 'light.room', rgb_color: [12, 34, 56], brightness_pct: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_on',
|
||||||
|
serviceData: { entity_id: 'light.room', color_temp_kelvin: 4500, brightness_pct: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'light',
|
||||||
|
service: 'turn_off',
|
||||||
|
serviceData: { entity_id: 'light.room' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'switch',
|
||||||
|
service: 'turn_on',
|
||||||
|
serviceData: { entity_id: 'switch.lamp' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -4,7 +4,14 @@
|
|||||||
const { httpServer } = require('../../globals/http');
|
const { httpServer } = require('../../globals/http');
|
||||||
const config = require('../../globals/config');
|
const config = require('../../globals/config');
|
||||||
const logger = require('../../globals/logger').child('httpServer');
|
const logger = require('../../globals/logger').child('httpServer');
|
||||||
|
const { startMediaMtx } = require('../mediaMtxService');
|
||||||
|
|
||||||
httpServer.listen(config.port, () => {
|
httpServer.listen(config.port, () => {
|
||||||
logger.info(`Server listening on :${config.port}`);
|
logger.info(`Server listening on :${config.port}`);
|
||||||
|
/*
|
||||||
|
MediaMTX immediately calls the server's HTTP authorization route when clients connect.
|
||||||
|
Starting it from the listen callback guarantees that endpoint is reachable before the
|
||||||
|
first publisher attempts to authenticate.
|
||||||
|
*/
|
||||||
|
startMediaMtx();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const {
|
|||||||
removeUserSignal,
|
removeUserSignal,
|
||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
|
setMuted,
|
||||||
|
setUserPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
setFeatureState,
|
setFeatureState,
|
||||||
deleteFeatureState,
|
deleteFeatureState,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -70,6 +73,11 @@ function ackHandler(socket, eventName, handler) {
|
|||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
ackHandler(socket, 'identityAdmin:listUsers', () => ({
|
||||||
users: listUsersForAdmin(),
|
users: listUsersForAdmin(),
|
||||||
|
permissions: listRegisteredPermissions(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
|
||||||
|
permissions: listRegisteredPermissions(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
|
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
|
||||||
@@ -103,6 +111,22 @@ io.on('connection', (socket) => {
|
|||||||
}).id),
|
}).id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
ackHandler(socket, 'identityAdmin:setMuted', ({ userId, enabled }) => ({
|
||||||
|
user: getUserForAdmin(setMuted(userId, {
|
||||||
|
enabled: Boolean(enabled),
|
||||||
|
actor: socket?.data?.user?.username || socket.id,
|
||||||
|
at: Date.now(),
|
||||||
|
}).id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ackHandler(socket, 'identityAdmin:setPermission', ({ userId, permissionKey, enabled }) => ({
|
||||||
|
user: getUserForAdmin(setUserPermission(userId, permissionKey, {
|
||||||
|
enabled: Boolean(enabled),
|
||||||
|
actor: socket?.data?.user?.username || socket.id,
|
||||||
|
at: Date.now(),
|
||||||
|
}).id),
|
||||||
|
}));
|
||||||
|
|
||||||
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
|
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
|
||||||
const normalized = normalizeFeaturePayload(namespace, value);
|
const normalized = normalizeFeaturePayload(namespace, value);
|
||||||
setFeatureState(userId, normalized.namespace, normalized.value);
|
setFeatureState(userId, normalized.namespace, normalized.value);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const Database = require('better-sqlite3');
|
|||||||
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
|
||||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const logger = require('../../globals/logger').child('identityService');
|
const logger = require('../../globals/logger').child('identityService');
|
||||||
|
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
|
||||||
|
|
||||||
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
|
||||||
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
|
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
|
||||||
@@ -17,7 +18,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
|
|||||||
const DB_PATH = resolveDataPath('identity.sqlite');
|
const DB_PATH = resolveDataPath('identity.sqlite');
|
||||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||||
const STORE_VERSION = 1;
|
const STORE_VERSION = 4;
|
||||||
const identityEvents = new EventEmitter();
|
const identityEvents = new EventEmitter();
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
@@ -166,7 +167,10 @@ function ensureSchema(conn) {
|
|||||||
deterrence_enabled integer not null default 0,
|
deterrence_enabled integer not null default 0,
|
||||||
deterrence_reason text,
|
deterrence_reason text,
|
||||||
deterrence_at integer,
|
deterrence_at integer,
|
||||||
deterrence_by text
|
deterrence_by text,
|
||||||
|
muted_enabled integer not null default 0,
|
||||||
|
muted_at integer,
|
||||||
|
muted_by text
|
||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists verification_requests (
|
create table if not exists verification_requests (
|
||||||
@@ -202,6 +206,15 @@ function ensureSchema(conn) {
|
|||||||
primary key (user_id, namespace)
|
primary key (user_id, namespace)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table if not exists user_permissions (
|
||||||
|
user_id text not null references users(id) on delete cascade,
|
||||||
|
permission_key text not null,
|
||||||
|
granted_at integer not null,
|
||||||
|
granted_by text,
|
||||||
|
primary key (user_id, permission_key)
|
||||||
|
);
|
||||||
|
create index if not exists idx_user_permissions_key on user_permissions(permission_key);
|
||||||
|
|
||||||
create table if not exists legacy_imports (
|
create table if not exists legacy_imports (
|
||||||
source text not null,
|
source text not null,
|
||||||
legacy_id text not null,
|
legacy_id text not null,
|
||||||
@@ -213,6 +226,33 @@ function ensureSchema(conn) {
|
|||||||
|
|
||||||
pragma user_version = ${STORE_VERSION};
|
pragma user_version = ${STORE_VERSION};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
/*
|
||||||
|
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||||
|
Add the mute columns explicitly for installations created before store
|
||||||
|
version 2. Permission grants now live in their own normalized table, so the
|
||||||
|
obsolete audio-specific status columns are deliberately removed instead of
|
||||||
|
carrying old grants into the new capability system.
|
||||||
|
*/
|
||||||
|
const statusColumns = new Set(
|
||||||
|
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||||
|
);
|
||||||
|
if (!statusColumns.has('muted_enabled')) {
|
||||||
|
conn.exec('alter table user_status add column muted_enabled integer not null default 0');
|
||||||
|
}
|
||||||
|
if (!statusColumns.has('muted_at')) {
|
||||||
|
conn.exec('alter table user_status add column muted_at integer');
|
||||||
|
}
|
||||||
|
if (!statusColumns.has('muted_by')) {
|
||||||
|
conn.exec('alter table user_status add column muted_by text');
|
||||||
|
}
|
||||||
|
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
|
||||||
|
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
|
||||||
|
});
|
||||||
|
// Old personal fractions were identity-backed feature state. The replacement
|
||||||
|
// is intentionally browser-local, so retaining these unreachable rows would
|
||||||
|
// make the database page imply that they still control runtime behavior.
|
||||||
|
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUser(conn = getDb(), ts = nowMs()) {
|
function createUser(conn = getDb(), ts = nowMs()) {
|
||||||
@@ -257,6 +297,13 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
|
|||||||
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
|
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
|
||||||
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
||||||
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
||||||
|
/*
|
||||||
|
Permissions describe the person, not one browser signal. Merging identities
|
||||||
|
therefore unions their grants before the source user is deleted; a conflict
|
||||||
|
keeps the target row and its original audit metadata.
|
||||||
|
*/
|
||||||
|
conn.prepare('update or ignore user_permissions set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
|
||||||
|
conn.prepare('delete from user_permissions where user_id = ?').run(sourceUserId);
|
||||||
|
|
||||||
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
|
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
|
||||||
ensureUserStatus(conn, targetUserId);
|
ensureUserStatus(conn, targetUserId);
|
||||||
@@ -279,6 +326,20 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
|
|||||||
where user_id = ?
|
where user_id = ?
|
||||||
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
|
`).run(sourceStatus.deterrence_reason || null, sourceStatus.deterrence_at || ts, sourceStatus.deterrence_by || null, targetUserId);
|
||||||
}
|
}
|
||||||
|
if (sourceStatus?.muted_enabled) {
|
||||||
|
/*
|
||||||
|
Identity merging must preserve the stricter moderation state. Otherwise
|
||||||
|
joining two signals could silently clear a mute merely because the
|
||||||
|
unmuted record happened to become the merge target.
|
||||||
|
*/
|
||||||
|
conn.prepare(`
|
||||||
|
update user_status
|
||||||
|
set muted_enabled = 1,
|
||||||
|
muted_at = coalesce(muted_at, ?),
|
||||||
|
muted_by = coalesce(muted_by, ?)
|
||||||
|
where user_id = ?
|
||||||
|
`).run(sourceStatus.muted_at || ts, sourceStatus.muted_by || null, targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
|
const sourceFeatures = conn.prepare('select namespace, data_json, created_at, updated_at from user_feature_state where user_id = ?').all(sourceUserId);
|
||||||
sourceFeatures.forEach((feature) => {
|
sourceFeatures.forEach((feature) => {
|
||||||
@@ -382,6 +443,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
|||||||
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
|
socket.data.verifiedRecordId = user.verified?.enabled ? user.id : null;
|
||||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||||
|
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
@@ -422,6 +484,7 @@ function identifySocket(socket, payload = {}) {
|
|||||||
fingerprintId: fingerprintId || null,
|
fingerprintId: fingerprintId || null,
|
||||||
isVerified: Boolean(user.verified?.enabled),
|
isVerified: Boolean(user.verified?.enabled),
|
||||||
isDeterred: Boolean(user.deterrence?.enabled),
|
isDeterred: Boolean(user.deterrence?.enabled),
|
||||||
|
isMuted: Boolean(user.deterrence?.muted),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +531,11 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
|||||||
reason: status.deterrence_reason || null,
|
reason: status.deterrence_reason || null,
|
||||||
at: status.deterrence_at || null,
|
at: status.deterrence_at || null,
|
||||||
by: status.deterrence_by || null,
|
by: status.deterrence_by || null,
|
||||||
|
muted: Boolean(status.muted_enabled),
|
||||||
|
mutedAt: status.muted_at || null,
|
||||||
|
mutedBy: status.muted_by || null,
|
||||||
},
|
},
|
||||||
|
permissions: getUserPermissions(id, { conn }),
|
||||||
features,
|
features,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -673,6 +740,75 @@ function setDeterrence(userId, { enabled = true, reason = null, actor = null, at
|
|||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||||
|
const id = String(userId || '').trim();
|
||||||
|
if (!id) throw new Error('userId required');
|
||||||
|
ensureUserStatus(getDb(), id);
|
||||||
|
getDb().prepare(`
|
||||||
|
update user_status
|
||||||
|
set muted_enabled = ?, muted_at = ?, muted_by = ?
|
||||||
|
where user_id = ?
|
||||||
|
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
||||||
|
identityEvents.emit('change', { reason: enabled ? 'muted' : 'unmuted', userId: id });
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Positive capabilities use normalized rows rather than feature-specific status
|
||||||
|
columns. This keeps moderation state focused and gives future permissions the
|
||||||
|
same audited grant/revoke path without another schema alteration.
|
||||||
|
*/
|
||||||
|
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||||
|
const id = String(userId || '').trim();
|
||||||
|
if (!id) throw new Error('userId required');
|
||||||
|
const permission = requireRegisteredPermission(permissionKey);
|
||||||
|
const conn = getDb();
|
||||||
|
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
|
||||||
|
if (enabled) {
|
||||||
|
conn.prepare(`
|
||||||
|
insert into user_permissions (user_id, permission_key, granted_at, granted_by)
|
||||||
|
values (?, ?, ?, ?)
|
||||||
|
on conflict(user_id, permission_key) do update set granted_at = excluded.granted_at, granted_by = excluded.granted_by
|
||||||
|
`).run(id, permission.key, at, actor ? String(actor) : null);
|
||||||
|
} else {
|
||||||
|
conn.prepare('delete from user_permissions where user_id = ? and permission_key = ?').run(id, permission.key);
|
||||||
|
}
|
||||||
|
identityEvents.emit('change', {
|
||||||
|
reason: enabled ? 'permission_granted' : 'permission_revoked',
|
||||||
|
userId: id,
|
||||||
|
permissionKey: permission.key,
|
||||||
|
});
|
||||||
|
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserPermissions(userId, { conn = getDb() } = {}) {
|
||||||
|
const id = String(userId || '').trim();
|
||||||
|
if (!id) return [];
|
||||||
|
return conn.prepare(`
|
||||||
|
select permission_key as key, granted_at as grantedAt, granted_by as grantedBy
|
||||||
|
from user_permissions
|
||||||
|
where user_id = ?
|
||||||
|
order by permission_key
|
||||||
|
`).all(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserPermission(userId, permissionKey, { conn = getDb() } = {}) {
|
||||||
|
const id = String(userId || '').trim();
|
||||||
|
const permission = requireRegisteredPermission(permissionKey);
|
||||||
|
if (!id) return false;
|
||||||
|
return Boolean(conn.prepare('select 1 from user_permissions where user_id = ? and permission_key = ?').get(id, permission.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listUsersWithPermission(permissionKey) {
|
||||||
|
const permission = requireRegisteredPermission(permissionKey);
|
||||||
|
const conn = getDb();
|
||||||
|
return conn.prepare('select user_id from user_permissions where permission_key = ? order by granted_at desc')
|
||||||
|
.all(permission.key)
|
||||||
|
.map((row) => getUserById(row.user_id, { conn, includeFeatures: false }))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function isVerified(socket) {
|
function isVerified(socket) {
|
||||||
return Boolean(socket?.data?.isVerified);
|
return Boolean(socket?.data?.isVerified);
|
||||||
}
|
}
|
||||||
@@ -681,12 +817,13 @@ function isDeterred(socket) {
|
|||||||
return Boolean(socket?.data?.isDeterred);
|
return Boolean(socket?.data?.isDeterred);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listUsers({ verified = null, deterred = null } = {}) {
|
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
|
||||||
const conn = getDb();
|
const conn = getDb();
|
||||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||||
const where = [];
|
const where = [];
|
||||||
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
||||||
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
||||||
|
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
||||||
if (where.length) sql += ` where ${where.join(' and ')}`;
|
if (where.length) sql += ` where ${where.join(' and ')}`;
|
||||||
sql += ' order by users.updated_at desc';
|
sql += ' order by users.updated_at desc';
|
||||||
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
||||||
@@ -705,6 +842,10 @@ function userToLegacyIdentityEntry(user) {
|
|||||||
updatedAt: user.updatedAt,
|
updatedAt: user.updatedAt,
|
||||||
approvedBy: user.verified?.by || null,
|
approvedBy: user.verified?.by || null,
|
||||||
reason: user.deterrence?.reason || null,
|
reason: user.deterrence?.reason || null,
|
||||||
|
muted: Boolean(user.deterrence?.muted),
|
||||||
|
mutedAt: user.deterrence?.mutedAt || null,
|
||||||
|
mutedBy: user.deterrence?.mutedBy || null,
|
||||||
|
permissions: (user.permissions || []).map((permission) => permission.key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +857,10 @@ function listDeterredUsers() {
|
|||||||
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
|
return listUsers({ deterred: true }).map(userToLegacyIdentityEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listMutedUsers() {
|
||||||
|
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||||
const value = String(selector || '').trim();
|
const value = String(selector || '').trim();
|
||||||
if (!value) return { error: 'selector_required' };
|
if (!value) return { error: 'selector_required' };
|
||||||
@@ -969,10 +1114,17 @@ module.exports = {
|
|||||||
listFeatureStates,
|
listFeatureStates,
|
||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
|
setMuted,
|
||||||
|
setUserPermission,
|
||||||
|
getUserPermissions,
|
||||||
|
hasUserPermission,
|
||||||
|
listUsersWithPermission,
|
||||||
|
listRegisteredPermissions,
|
||||||
isVerified,
|
isVerified,
|
||||||
isDeterred,
|
isDeterred,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
|
listMutedUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
createJsonStore,
|
createJsonStore,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Identity Permission Registry
|
||||||
|
// Purpose: Defines every positive capability that can be granted to a canonical user.
|
||||||
|
// Scope: Keeps stable database keys and operator-facing descriptions centralized so services and admin tools cannot invent mismatched permission names.
|
||||||
|
const USER_PERMISSIONS = Object.freeze({
|
||||||
|
'audio.personalAdjustment': Object.freeze({
|
||||||
|
key: 'audio.personalAdjustment',
|
||||||
|
commandName: 'audio-adjustment',
|
||||||
|
label: 'Personal audio adjustment',
|
||||||
|
description: 'Allows personal horn, text-to-speech, and microphone volume adjustments.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function listRegisteredPermissions() {
|
||||||
|
return Object.values(USER_PERMISSIONS).map((permission) => ({ ...permission }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRegisteredPermission(permissionKey) {
|
||||||
|
const key = String(permissionKey || '').trim().toLowerCase();
|
||||||
|
const permission = Object.values(USER_PERMISSIONS).find((entry) => (
|
||||||
|
entry.key.toLowerCase() === key || entry.commandName.toLowerCase() === key
|
||||||
|
));
|
||||||
|
if (!permission) throw new Error('Unknown user permission.');
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
USER_PERMISSIONS,
|
||||||
|
listRegisteredPermissions,
|
||||||
|
requireRegisteredPermission,
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Identity Permission Storage Tests
|
||||||
|
// Purpose: Verifies normalized grants, registry validation, and the intentionally empty replacement for legacy audio boost flags.
|
||||||
|
// Scope: Uses an isolated temporary data directory and never opens the development identity database.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rover-identity-permissions-'));
|
||||||
|
process.env.SERVER_DATA_DIR = testDataDir;
|
||||||
|
/*
|
||||||
|
Seed the exact legacy concern this redesign removes. Opening identityService
|
||||||
|
must preserve the real moderation fields while dropping all old boost grants
|
||||||
|
instead of translating them into the new permission table.
|
||||||
|
*/
|
||||||
|
const legacyDb = new Database(path.join(testDataDir, 'identity.sqlite'));
|
||||||
|
legacyDb.exec(`
|
||||||
|
create table users (
|
||||||
|
id text primary key,
|
||||||
|
created_at integer not null,
|
||||||
|
updated_at integer not null,
|
||||||
|
last_seen_at integer
|
||||||
|
);
|
||||||
|
create table user_status (
|
||||||
|
user_id text primary key references users(id) on delete cascade,
|
||||||
|
verified_enabled integer not null default 0,
|
||||||
|
verified_at integer,
|
||||||
|
verified_by text,
|
||||||
|
deterrence_enabled integer not null default 0,
|
||||||
|
deterrence_reason text,
|
||||||
|
deterrence_at integer,
|
||||||
|
deterrence_by text,
|
||||||
|
muted_enabled integer not null default 0,
|
||||||
|
muted_at integer,
|
||||||
|
muted_by text,
|
||||||
|
audio_gain_boost_enabled integer not null default 0,
|
||||||
|
audio_gain_boost_at integer,
|
||||||
|
audio_gain_boost_by text
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
legacyDb.close();
|
||||||
|
const identityService = require('./index');
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
identityService.getDb().close();
|
||||||
|
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy audio boost columns are removed and normalized permissions are created empty', () => {
|
||||||
|
const db = identityService.getDb();
|
||||||
|
const statusColumns = db.prepare('pragma table_info(user_status)').all().map((column) => column.name);
|
||||||
|
|
||||||
|
assert.doesNotMatch(statusColumns.join(','), /audio_gain_boost/);
|
||||||
|
assert.ok(db.prepare("select 1 from sqlite_master where type = 'table' and name = 'user_permissions'").get());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registered permissions can be granted, listed, queried, and revoked', () => {
|
||||||
|
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_11111111111111111111111111111111' });
|
||||||
|
|
||||||
|
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
|
||||||
|
identityService.setUserPermission(userId, 'audio-adjustment', { enabled: true, actor: 'test-admin', at: 1234 });
|
||||||
|
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), true);
|
||||||
|
assert.deepEqual(identityService.getUserPermissions(userId), [{
|
||||||
|
key: 'audio.personalAdjustment',
|
||||||
|
grantedAt: 1234,
|
||||||
|
grantedBy: 'test-admin',
|
||||||
|
}]);
|
||||||
|
assert.equal(identityService.listUsersWithPermission('audio-adjustment')[0].id, userId);
|
||||||
|
|
||||||
|
identityService.setUserPermission(userId, 'audio.personalAdjustment', { enabled: false });
|
||||||
|
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown permission keys cannot be persisted', () => {
|
||||||
|
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_22222222222222222222222222222222' });
|
||||||
|
assert.throws(
|
||||||
|
() => identityService.setUserPermission(userId, 'made.up.permission', { enabled: true }),
|
||||||
|
/Unknown user permission/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// MediaMTX Config Builder
|
||||||
|
// Purpose: Converts the rover server's media settings into the complete MediaMTX runtime configuration.
|
||||||
|
// Scope: Keeps deployment-specific hosts in config.yaml while keeping protocol policy owned by the application.
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function normalizeAdditionalHosts(rawHosts) {
|
||||||
|
if (rawHosts == null) return [];
|
||||||
|
if (!Array.isArray(rawHosts)) {
|
||||||
|
throw new Error('media.additionalHosts must be a list');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
MediaMTX accepts both IP addresses and DNS names here. Preserve that flexibility because
|
||||||
|
an installation can need a public candidate and a LAN candidate at the same time. Empty
|
||||||
|
entries and duplicates are removed so a harmless config typo does not create redundant
|
||||||
|
ICE candidates, while the values themselves remain entirely instance-owned.
|
||||||
|
*/
|
||||||
|
return [...new Set(rawHosts.map((value) => String(value || '').trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||||
|
const media = config?.media || {};
|
||||||
|
let additionalHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||||
|
if (!additionalHosts.length && media.whepBaseUrl) {
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Existing installations predate media.additionalHosts. Using the already-configured
|
||||||
|
WHEP hostname as a one-host migration default keeps them reachable on first restart;
|
||||||
|
administrators can still list every public and LAN candidate explicitly afterward.
|
||||||
|
*/
|
||||||
|
additionalHosts = [new URL(media.whepBaseUrl).hostname].filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
throw new Error('media.whepBaseUrl must be a valid URL when media.additionalHosts is empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const authPort = Number(serverPort) || 8080;
|
||||||
|
|
||||||
|
return {
|
||||||
|
logLevel: 'info',
|
||||||
|
api: true,
|
||||||
|
apiAddress: '127.0.0.1:9997',
|
||||||
|
metrics: true,
|
||||||
|
metricsAddress: '127.0.0.1:9998',
|
||||||
|
pprof: false,
|
||||||
|
pprofAddress: '127.0.0.1:9999',
|
||||||
|
|
||||||
|
/*
|
||||||
|
Rover publishers and readers always request TCP explicitly. Declaring only TCP here
|
||||||
|
also prevents MediaMTX from opening the separate RTP/RTCP UDP listeners, which are not
|
||||||
|
useful for this local-network deployment and performed poorly in the measured tests.
|
||||||
|
*/
|
||||||
|
rtsp: true,
|
||||||
|
rtspAddress: ':8554',
|
||||||
|
rtspTransports: ['tcp'],
|
||||||
|
rtmp: false,
|
||||||
|
hls: false,
|
||||||
|
|
||||||
|
webrtc: true,
|
||||||
|
webrtcLocalUDPAddress: ':8189',
|
||||||
|
webrtcLocalTCPAddress: ':8189',
|
||||||
|
webrtcAdditionalHosts: additionalHosts,
|
||||||
|
webrtcICEServers2: [
|
||||||
|
{ 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' },
|
||||||
|
{ url: 'stun:stun.cloudflare.com:3478' },
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
Several server-local paths still use SRT: PTZ publishing, replay capture, and the snapshot
|
||||||
|
writer. Rover media moves to RTSP, but removing this listener would break those independent
|
||||||
|
consumers, so both listeners remain deliberately enabled.
|
||||||
|
*/
|
||||||
|
srt: true,
|
||||||
|
srtAddress: ':9000',
|
||||||
|
|
||||||
|
authMethod: 'http',
|
||||||
|
authHTTPAddress: `http://127.0.0.1:${authPort}/mediamtx/auth`,
|
||||||
|
authHTTPExclude: [
|
||||||
|
{ action: 'api' },
|
||||||
|
{ action: 'metrics' },
|
||||||
|
{ action: 'pprof' },
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
all: {
|
||||||
|
source: 'publisher',
|
||||||
|
sourceOnDemand: false,
|
||||||
|
runOnReady: path.resolve(snapshotWriterPath),
|
||||||
|
runOnReadyRestart: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildMediaMtxConfig,
|
||||||
|
normalizeAdditionalHosts,
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// MediaMTX Config Builder Tests
|
||||||
|
// Purpose: Pins the generated protocol policy and instance-specific ICE host handling.
|
||||||
|
// Scope: Tests pure configuration output without starting listeners or leaving a child process running.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { buildMediaMtxConfig, normalizeAdditionalHosts } = require('./config');
|
||||||
|
|
||||||
|
test('generates RTSP over TCP without deployment-specific hardcodes', () => {
|
||||||
|
const generated = buildMediaMtxConfig({
|
||||||
|
config: {
|
||||||
|
media: {
|
||||||
|
whepBaseUrl: 'http://media.internal:8889/video',
|
||||||
|
additionalHosts: ['public.example.com', '10.20.30.40'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serverPort: 8123,
|
||||||
|
snapshotWriterPath: '/opt/multirover/rover-snapshot-writer.sh',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(generated.rtsp, true);
|
||||||
|
assert.equal(generated.rtspAddress, ':8554');
|
||||||
|
assert.deepEqual(generated.rtspTransports, ['tcp']);
|
||||||
|
assert.equal(Object.hasOwn(generated, 'rtpAddress'), false);
|
||||||
|
assert.equal(Object.hasOwn(generated, 'rtcpAddress'), false);
|
||||||
|
assert.deepEqual(generated.webrtcAdditionalHosts, ['public.example.com', '10.20.30.40']);
|
||||||
|
assert.equal(generated.authHTTPAddress, 'http://127.0.0.1:8123/mediamtx/auth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the configured WHEP hostname while an older config has no additionalHosts', () => {
|
||||||
|
const generated = buildMediaMtxConfig({
|
||||||
|
config: { media: { whepBaseUrl: 'https://second-server.example/video' } },
|
||||||
|
serverPort: 8080,
|
||||||
|
snapshotWriterPath: '/usr/local/bin/rover-snapshot-writer.sh',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(generated.webrtcAdditionalHosts, ['second-server.example']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes duplicate and empty additional hosts', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeAdditionalHosts([' media.local ', '', 'media.local', null, 'public.example']),
|
||||||
|
['media.local', 'public.example'],
|
||||||
|
);
|
||||||
|
assert.throws(() => normalizeAdditionalHosts('media.local'), /must be a list/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// MediaMTX Service
|
||||||
|
// Purpose: Composes server configuration, runtime paths, and child-process supervision.
|
||||||
|
// Scope: Starts MediaMTX only after the HTTP auth endpoint is listening and stops it with the server.
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const globalConfig = require('../../globals/config');
|
||||||
|
const logger = require('../../globals/logger').child('mediamtx');
|
||||||
|
const { createMediaMtxSupervisor } = require('./supervisor');
|
||||||
|
|
||||||
|
const supervisor = createMediaMtxSupervisor({
|
||||||
|
config: loadConfig(),
|
||||||
|
serverPort: globalConfig.port,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
function startMediaMtx() {
|
||||||
|
return supervisor.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Other services already use process signal hooks for their own workers. This hook performs
|
||||||
|
only synchronous signal delivery; systemd's default control-group cleanup remains the final
|
||||||
|
guarantee if the parent is killed before the child finishes exiting.
|
||||||
|
*/
|
||||||
|
process.once('exit', () => supervisor.stop());
|
||||||
|
|
||||||
|
function stopForSignal(signal) {
|
||||||
|
let completed = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (completed) return;
|
||||||
|
completed = true;
|
||||||
|
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||||
|
};
|
||||||
|
|
||||||
|
supervisor.stop(finish);
|
||||||
|
/*
|
||||||
|
A wedged child must not make systemd wait indefinitely. This timer is deliberately unref'd
|
||||||
|
so it never keeps an otherwise-finished process alive; it is only a bound on graceful exit.
|
||||||
|
*/
|
||||||
|
const forceExitTimer = setTimeout(finish, 5000);
|
||||||
|
forceExitTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', () => stopForSignal('SIGINT'));
|
||||||
|
process.once('SIGTERM', () => stopForSignal('SIGTERM'));
|
||||||
|
|
||||||
|
module.exports = { startMediaMtx };
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// MediaMTX Child Supervisor
|
||||||
|
// Purpose: Writes the generated runtime configuration and owns the MediaMTX child process lifecycle.
|
||||||
|
// Scope: Starts exactly one child, forwards its logs, and lets systemd restart the coherent server/media pair.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const { buildMediaMtxConfig } = require('./config');
|
||||||
|
|
||||||
|
function createMediaMtxSupervisor(deps) {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
serverPort,
|
||||||
|
logger,
|
||||||
|
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
|
||||||
|
configPath = resolveDataPath('mediamtx.yml'),
|
||||||
|
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
|
||||||
|
spawnProcess = spawn,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
let child = null;
|
||||||
|
let stopping = false;
|
||||||
|
let stoppedCallback = null;
|
||||||
|
|
||||||
|
function forwardLines(stream, level) {
|
||||||
|
let pending = '';
|
||||||
|
stream.setEncoding('utf8');
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
pending += chunk;
|
||||||
|
const lines = pending.split(/\r?\n/);
|
||||||
|
pending = lines.pop() || '';
|
||||||
|
lines.filter(Boolean).forEach((line) => logger[level](line));
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (pending) logger[level](pending);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (child) return child;
|
||||||
|
|
||||||
|
const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
|
||||||
|
/*
|
||||||
|
Generated MediaMTX state belongs beside the server's other owned data. Using the shared
|
||||||
|
data-path helper honors SERVER_DATA_DIR as well as the normal server/data directory and
|
||||||
|
avoids introducing a systemd-created /run directory with separate permission rules.
|
||||||
|
*/
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.writeFileSync(configPath, yaml.dump(generatedConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
|
||||||
|
|
||||||
|
logger.info(`Starting MediaMTX with generated config ${configPath}`);
|
||||||
|
child = spawnProcess(mediaMtxBin, [configPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
forwardLines(child.stdout, 'info');
|
||||||
|
forwardLines(child.stderr, 'warn');
|
||||||
|
|
||||||
|
child.once('error', (err) => {
|
||||||
|
logger.error('Unable to start MediaMTX', err);
|
||||||
|
if (!stopping) {
|
||||||
|
/*
|
||||||
|
A spawn failure does not reliably emit the normal exit event on every platform.
|
||||||
|
Fail the parent here as well so the server can never stay nominally online without
|
||||||
|
its required media child and systemd gets the opportunity to repair the launch.
|
||||||
|
*/
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
child = null;
|
||||||
|
if (stopping) {
|
||||||
|
stoppedCallback?.();
|
||||||
|
stoppedCallback = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
MediaMTX is required for every live media path. Exiting the parent is intentionally
|
||||||
|
simpler and safer than maintaining a second retry policy inside Node: systemd already
|
||||||
|
restarts multirover.service, producing one clean server/MediaMTX lifecycle.
|
||||||
|
*/
|
||||||
|
logger.error(`MediaMTX exited unexpectedly (code=${code ?? 'none'} signal=${signal || 'none'})`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(onStopped) {
|
||||||
|
stopping = true;
|
||||||
|
stoppedCallback = typeof onStopped === 'function' ? onStopped : null;
|
||||||
|
if (!child) {
|
||||||
|
stoppedCallback?.();
|
||||||
|
stoppedCallback = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Unable to stop MediaMTX cleanly', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMediaMtxSupervisor };
|
||||||
@@ -9,6 +9,7 @@ const { isFeatureEnabled } = require('../../helpers/features');
|
|||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
|
const { sendAlert } = require('../alertService');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -31,6 +32,11 @@ function normalizeDeviceName(value) {
|
|||||||
|
|
||||||
const device = normalizeDeviceName(neatoConfig.device);
|
const device = normalizeDeviceName(neatoConfig.device);
|
||||||
const RESUME_DELAY_MS = 3000;
|
const RESUME_DELAY_MS = 3000;
|
||||||
|
const ALERT_COLOR = '#a855f7';
|
||||||
|
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
|
||||||
|
// allowlist on the server prevents arbitrary Home Assistant select options from
|
||||||
|
// being submitted by a modified browser while preserving BrainSlug's casing.
|
||||||
|
const NAVIGATION_MODES = Object.freeze(['Normal', 'Gentle', 'Deep', 'Quick']);
|
||||||
|
|
||||||
function entityId(domain, suffix) {
|
function entityId(domain, suffix) {
|
||||||
if (!device) return '';
|
if (!device) return '';
|
||||||
@@ -61,8 +67,27 @@ const ENTITY_IDS = {
|
|||||||
robotError: entityId('sensor', 'robot_error'),
|
robotError: entityId('sensor', 'robot_error'),
|
||||||
robotAlert: entityId('sensor', 'robot_alert'),
|
robotAlert: entityId('sensor', 'robot_alert'),
|
||||||
},
|
},
|
||||||
|
selects: {
|
||||||
|
navigationMode: entityId('select', 'navigation_mode'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Alert Feed coverage is intentionally limited to the raw robot lifecycle and
|
||||||
|
// issue fields requested for Neato. Battery and charger telemetry poll often and
|
||||||
|
// would create noise without representing a useful robot status transition.
|
||||||
|
const ALERT_ENTITIES = Object.freeze([
|
||||||
|
{ title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState },
|
||||||
|
{ title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState },
|
||||||
|
{ title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert },
|
||||||
|
{ title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError },
|
||||||
|
{ title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Each entity establishes its own baseline because ESPHome entities can become
|
||||||
|
// available on different snapshots. A Map also distinguishes "not observed yet"
|
||||||
|
// from a legitimate raw state string without inventing a sentinel state value.
|
||||||
|
const alertBaselines = new Map();
|
||||||
|
|
||||||
function readRaw(entityIdValue) {
|
function readRaw(entityIdValue) {
|
||||||
if (!entityIdValue) return null;
|
if (!entityIdValue) return null;
|
||||||
return getRawEntitySnapshot(entityIdValue);
|
return getRawEntitySnapshot(entityIdValue);
|
||||||
@@ -94,6 +119,32 @@ function isEntityAvailable(entityIdValue) {
|
|||||||
return state !== 'unavailable';
|
return state !== 'unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitRawStateAlerts() {
|
||||||
|
for (const { title, entityId: entityIdValue } of ALERT_ENTITIES) {
|
||||||
|
const raw = readState(entityIdValue);
|
||||||
|
const normalized = String(raw ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
// Missing and unavailable values commonly occur while Home Assistant or the
|
||||||
|
// ESPHome device reconnects. Ignoring them preserves the last real baseline
|
||||||
|
// and prevents connection churn from becoming misleading Neato activity.
|
||||||
|
if (!normalized || normalized === 'unavailable' || normalized === 'unknown') continue;
|
||||||
|
|
||||||
|
const rawMessage = String(raw);
|
||||||
|
if (!alertBaselines.has(entityIdValue)) {
|
||||||
|
// The first real value is startup state, not a transition caused while the
|
||||||
|
// service was watching, so record it without creating an Alert Feed toast.
|
||||||
|
alertBaselines.set(entityIdValue, rawMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (alertBaselines.get(entityIdValue) === rawMessage) continue;
|
||||||
|
|
||||||
|
alertBaselines.set(entityIdValue, rawMessage);
|
||||||
|
// The title provides field context, while the message remains exactly the
|
||||||
|
// new Home Assistant state with no friendly translation or previous value.
|
||||||
|
sendAlert({ color: ALERT_COLOR, title, message: rawMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function requiredEntityIds() {
|
function requiredEntityIds() {
|
||||||
return [
|
return [
|
||||||
ENTITY_IDS.buttons.start,
|
ENTITY_IDS.buttons.start,
|
||||||
@@ -146,6 +197,14 @@ function buildState() {
|
|||||||
entityId: ENTITY_IDS.buttons.powerCycle,
|
entityId: ENTITY_IDS.buttons.powerCycle,
|
||||||
available: hasEntity(ENTITY_IDS.buttons.powerCycle),
|
available: hasEntity(ENTITY_IDS.buttons.powerCycle),
|
||||||
},
|
},
|
||||||
|
navigationMode: {
|
||||||
|
entityId: ENTITY_IDS.selects.navigationMode,
|
||||||
|
available: isEntityAvailable(ENTITY_IDS.selects.navigationMode),
|
||||||
|
value: readState(ENTITY_IDS.selects.navigationMode),
|
||||||
|
// The browser receives the supported choices through the session contract
|
||||||
|
// instead of duplicating BrainSlug-specific values in the presentation layer.
|
||||||
|
options: NAVIGATION_MODES,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const batteryPercentValue = parseNumber(readState(ENTITY_IDS.sensors.batteryPercent));
|
const batteryPercentValue = parseNumber(readState(ENTITY_IDS.sensors.batteryPercent));
|
||||||
@@ -199,6 +258,7 @@ if (featureEnabled) {
|
|||||||
*/
|
*/
|
||||||
homeAssistantEvents.on('snapshot', () => {
|
homeAssistantEvents.on('snapshot', () => {
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
emitRawStateAlerts();
|
||||||
});
|
});
|
||||||
|
|
||||||
homeAssistantEvents.on('status', () => {
|
homeAssistantEvents.on('status', () => {
|
||||||
@@ -255,6 +315,29 @@ async function powerCycle() {
|
|||||||
await pressButton(ENTITY_IDS.buttons.powerCycle, 'powercucle');
|
await pressButton(ENTITY_IDS.buttons.powerCycle, 'powercucle');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setNavigationMode(mode) {
|
||||||
|
assertConfiguredAndConnected();
|
||||||
|
const normalizedMode = String(mode || '').trim();
|
||||||
|
if (!NAVIGATION_MODES.includes(normalizedMode)) {
|
||||||
|
throw new Error('Invalid Neato navigation mode');
|
||||||
|
}
|
||||||
|
if (!isEntityAvailable(ENTITY_IDS.selects.navigationMode)) {
|
||||||
|
throw new Error('Neato action unavailable: navigation_mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESPHome implements Navigation Mode as a Home Assistant select entity, so
|
||||||
|
// select_option is the native service call and avoids sending raw UART commands.
|
||||||
|
await callHomeAssistantService('select', 'select_option', {
|
||||||
|
entity_id: ENTITY_IDS.selects.navigationMode,
|
||||||
|
option: normalizedMode,
|
||||||
|
});
|
||||||
|
logger.info('Issued Neato action', {
|
||||||
|
action: 'set_navigation_mode',
|
||||||
|
entityId: ENTITY_IDS.selects.navigationMode,
|
||||||
|
option: normalizedMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getState() {
|
function getState() {
|
||||||
cachedState = buildState();
|
cachedState = buildState();
|
||||||
return cachedState;
|
return cachedState;
|
||||||
@@ -328,6 +411,16 @@ if (featureEnabled) {
|
|||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('neato:setNavigationMode', async ({ mode } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
assertFeatureAccess();
|
||||||
|
await setNavigationMode(mode);
|
||||||
|
cb({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info('Neato disabled by config');
|
logger.info('Neato disabled by config');
|
||||||
@@ -342,5 +435,6 @@ module.exports = {
|
|||||||
locateRobot,
|
locateRobot,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
powerCycle,
|
powerCycle,
|
||||||
|
setNavigationMode,
|
||||||
neatoEvents: events,
|
neatoEvents: events,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,45 @@
|
|||||||
// Operator Deter Command
|
// Operator Deter Command
|
||||||
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
// Purpose: Handles deterrence moderation commands for lockdown admins.
|
||||||
// Scope: Supports list, ban, and unban subcommands.
|
// Scope: Supports list, ban, and unban subcommands.
|
||||||
const { mask, resolveIdentitySelector } = require('./resolvers');
|
const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
|
||||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, undeterUser, sanitizeMentions, config }) {
|
function findOnlineNicknameMatches(io, getNickname, selector) {
|
||||||
|
const normalizedSelector = normalizeSearchText(selector);
|
||||||
|
if (!normalizedSelector) return [];
|
||||||
|
|
||||||
|
const matchesByUserId = new Map();
|
||||||
|
const sockets = io?.sockets?.sockets;
|
||||||
|
if (!sockets || typeof sockets.forEach !== 'function') return [];
|
||||||
|
|
||||||
|
sockets.forEach((socket) => {
|
||||||
|
const nickname = getNickname(socket);
|
||||||
|
const userId = String(socket?.data?.userId || '').trim();
|
||||||
|
if (!userId || normalizeSearchText(nickname) !== normalizedSelector) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
One person may have multiple connected tabs or surfaces. Collapse those
|
||||||
|
sockets to the canonical user id so duplicate tabs do not manufacture an
|
||||||
|
ambiguous moderation target when they all represent the same identity.
|
||||||
|
*/
|
||||||
|
if (!matchesByUserId.has(userId)) {
|
||||||
|
matchesByUserId.set(userId, { userId, nickname });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(matchesByUserId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIdentityRecords(records = []) {
|
||||||
|
const byIdentity = new Map();
|
||||||
|
records.forEach((record) => {
|
||||||
|
const key = record?.userId || record?.id || record?.cookieUserId || record?.fingerprintId;
|
||||||
|
if (key && !byIdentity.has(key)) byIdentity.set(key, record);
|
||||||
|
});
|
||||||
|
return Array.from(byIdentity.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeterCommand({ io, getNickname, listDeterredUsers, listMutedUsers, listVerifiedUsers, deterUser, undeterUser, muteUser, unmuteUser, sanitizeMentions, config }) {
|
||||||
// Moderation usage errors use the same core prefix shown by organized help.
|
// Moderation usage errors use the same core prefix shown by organized help.
|
||||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
|
|
||||||
@@ -15,23 +50,58 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
}
|
}
|
||||||
const action = (tokens.shift() || 'list').toLowerCase();
|
const action = (tokens.shift() || 'list').toLowerCase();
|
||||||
if (action === 'list') {
|
if (action === 'list') {
|
||||||
const users = listDeterredUsers();
|
const deterredUsers = listDeterredUsers().map((entry) => ({ ...entry, deterred: true }));
|
||||||
if (!users.length) return message.reply({ content: 'No deterred users.', allowedMentions: { parse: [], repliedUser: false } });
|
const mutedUsers = listMutedUsers().map((entry) => ({ ...entry, muted: true }));
|
||||||
const lines = users.map((entry, idx) => `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${mask(entry.cookieUserId)}`);
|
const usersById = new Map();
|
||||||
return message.reply({ content: ['Deterred users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
[...deterredUsers, ...mutedUsers].forEach((entry) => {
|
||||||
|
const userId = entry.userId || entry.id;
|
||||||
|
if (!userId) return;
|
||||||
|
const existing = usersById.get(userId) || {};
|
||||||
|
usersById.set(userId, {
|
||||||
|
...existing,
|
||||||
|
...entry,
|
||||||
|
deterred: Boolean(existing.deterred || entry.deterred),
|
||||||
|
muted: Boolean(existing.muted || entry.muted),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const users = Array.from(usersById.values());
|
||||||
|
if (!users.length) return message.reply({ content: 'No deterred or muted users.', allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
const lines = users.map((entry, idx) => {
|
||||||
|
const flags = [entry.deterred ? 'deterred' : '', entry.muted ? 'muted' : ''].filter(Boolean).join(', ');
|
||||||
|
return `${idx + 1}. ${entry.userId || entry.id} | ${entry.nickname || 'unknown'} | ${flags} | ${mask(entry.cookieUserId)}`;
|
||||||
|
});
|
||||||
|
return message.reply({ content: ['Moderated users:', ...lines].join('\n').slice(0, 1900), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
if (action === 'ban') {
|
if (action === 'ban') {
|
||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ban <cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
try {
|
try {
|
||||||
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
|
||||||
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
if (onlineMatches.length > 1) {
|
||||||
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
|
/*
|
||||||
|
Identical live nicknames are genuinely ambiguous, so do not guess
|
||||||
|
for a destructive command. Unlike the old generic error, this
|
||||||
|
response exposes stable selectors that the administrator can copy
|
||||||
|
directly into a follow-up command.
|
||||||
|
*/
|
||||||
|
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
|
||||||
|
return message.reply({
|
||||||
|
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ban <userId>\`.`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let stableSelector = onlineMatches[0]?.userId || null;
|
||||||
|
if (!stableSelector) {
|
||||||
|
const verifiedMatch = resolveIdentitySelector(selector, listVerifiedUsers(), { includeId: false });
|
||||||
|
if (verifiedMatch.error && !/not found/i.test(verifiedMatch.error)) {
|
||||||
|
return message.reply({ content: sanitizeMentions(verifiedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
}
|
||||||
|
stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
|
||||||
}
|
}
|
||||||
// Ban reasons were deliberately removed from the command grammar. The
|
// Ban reasons were deliberately removed from the command grammar. The
|
||||||
// full remaining text is now always the selector, which lets lockdown
|
// full remaining text is now always the selector, which lets lockdown
|
||||||
// admins deter multi-word nicknames without quoting or delimiter rules.
|
// admins deter multi-word nicknames without quoting or delimiter rules.
|
||||||
const stableSelector = verifiedMatch.record?.userId || verifiedMatch.record?.id || verifiedMatch.record?.cookieUserId || selector;
|
|
||||||
const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
|
const deterred = deterUser(stableSelector, { actor: message.actor?.id || null });
|
||||||
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`${deterred.created ? 'Deterred' : 'Updated deterrence for'} ${deterred.nickname || 'unknown'} (${mask(deterred.cookieUserId)}).`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -50,8 +120,47 @@ function createDeterCommand({ listDeterredUsers, listVerifiedUsers, deterUser, u
|
|||||||
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
return message.reply({ content: sanitizeMentions(`Failed to remove deterrence: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, or \`${commandPrefix} deter unban <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
if (action === 'mute' || action === 'unmute') {
|
||||||
|
const selector = tokens.join(' ').trim();
|
||||||
|
if (!selector) return message.reply({ content: `Usage: \`${commandPrefix} deter ${action} <userId|cookieUserId|nickname|ip>\``, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
try {
|
||||||
|
const onlineMatches = findOnlineNicknameMatches(io, getNickname, selector);
|
||||||
|
if (onlineMatches.length > 1) {
|
||||||
|
const choices = onlineMatches.map((match) => `${match.nickname} (${match.userId})`).join(', ');
|
||||||
|
return message.reply({
|
||||||
|
content: sanitizeMentions(`More than one online user is named ${selector}: ${choices}. Retry with \`${commandPrefix} deter ${action} <userId>\`.`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let stableSelector = onlineMatches[0]?.userId || null;
|
||||||
|
if (!stableSelector) {
|
||||||
|
const storedCandidates = uniqueIdentityRecords([...listVerifiedUsers(), ...listMutedUsers()]);
|
||||||
|
const storedMatch = resolveIdentitySelector(selector, storedCandidates, { includeId: true });
|
||||||
|
if (storedMatch.error && !/not found/i.test(storedMatch.error)) {
|
||||||
|
return message.reply({ content: sanitizeMentions(storedMatch.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Unverified users may not appear in the convenience candidate list.
|
||||||
|
Passing the original exact selector through lets verificationService
|
||||||
|
resolve any canonical identity without making fuzzy guesses here.
|
||||||
|
*/
|
||||||
|
stableSelector = storedMatch.record?.userId || storedMatch.record?.id || storedMatch.record?.cookieUserId || selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = action === 'mute'
|
||||||
|
? muteUser(stableSelector, message.actor?.id || null)
|
||||||
|
: unmuteUser(stableSelector, message.actor?.id || null);
|
||||||
|
return message.reply({
|
||||||
|
content: sanitizeMentions(`${action === 'mute' ? 'Muted' : 'Unmuted'} ${updated.nickname || 'unknown'} (${mask(updated.cookieUserId)}).`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return message.reply({ content: sanitizeMentions(`Failed to ${action} user: ${err.message}`), allowedMentions: { parse: [], repliedUser: false } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message.reply({ content: `Unknown deter command. Use \`${commandPrefix} deter list\`, \`${commandPrefix} deter ban <selector>\`, \`${commandPrefix} deter unban <selector>\`, \`${commandPrefix} deter mute <selector>\`, or \`${commandPrefix} deter unmute <selector>\`.`, allowedMentions: { parse: [], repliedUser: false } });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createDeterCommand };
|
module.exports = { createDeterCommand, findOnlineNicknameMatches, uniqueIdentityRecords };
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Operator Deter Command Tests
|
||||||
|
// Purpose: Verifies that live nickname identity takes precedence over ambiguous stored aliases.
|
||||||
|
// Scope: Exercises only command target resolution with in-memory socket and identity doubles.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createDeterCommand } = require('./deter');
|
||||||
|
|
||||||
|
function createSocket(id, userId, nickname) {
|
||||||
|
return { id, data: { userId, nickname } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(sockets = [], verifiedUsers = []) {
|
||||||
|
const deterCalls = [];
|
||||||
|
const muteCalls = [];
|
||||||
|
const replies = [];
|
||||||
|
const handler = createDeterCommand({
|
||||||
|
io: { sockets: { sockets: new Map(sockets.map((socket) => [socket.id, socket])) } },
|
||||||
|
getNickname: (socket) => socket?.data?.nickname || '',
|
||||||
|
listDeterredUsers: () => [],
|
||||||
|
listMutedUsers: () => [],
|
||||||
|
listVerifiedUsers: () => verifiedUsers,
|
||||||
|
deterUser: (selector) => {
|
||||||
|
deterCalls.push(selector);
|
||||||
|
return { created: true, nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
|
||||||
|
},
|
||||||
|
undeterUser: () => null,
|
||||||
|
muteUser: (selector) => {
|
||||||
|
muteCalls.push({ action: 'mute', selector });
|
||||||
|
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
|
||||||
|
},
|
||||||
|
unmuteUser: (selector) => {
|
||||||
|
muteCalls.push({ action: 'unmute', selector });
|
||||||
|
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
|
||||||
|
},
|
||||||
|
sanitizeMentions: (value) => value,
|
||||||
|
config: { commands: { prefix: 'rs' } },
|
||||||
|
});
|
||||||
|
const message = {
|
||||||
|
actor: { id: 'admin', isLockdownAdmin: true },
|
||||||
|
reply: async (payload) => {
|
||||||
|
replies.push(payload);
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { handler, message, deterCalls, muteCalls, replies };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('prefers the one online exact nickname over ambiguous stored records', async () => {
|
||||||
|
const verifiedUsers = [
|
||||||
|
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
|
||||||
|
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
|
||||||
|
];
|
||||||
|
const { handler, message, deterCalls } = createHarness([
|
||||||
|
createSocket('socket-1', 'live-user', 'Croissant'),
|
||||||
|
], verifiedUsers);
|
||||||
|
|
||||||
|
await handler(message, ['ban', 'croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(deterCalls, ['live-user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses multiple sockets belonging to the same online identity', async () => {
|
||||||
|
const { handler, message, deterCalls } = createHarness([
|
||||||
|
createSocket('socket-1', 'live-user', 'Croissant'),
|
||||||
|
createSocket('socket-2', 'live-user', 'croissant'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await handler(message, ['ban', 'Croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(deterCalls, ['live-user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns usable user ids when different online identities share a nickname', async () => {
|
||||||
|
const { handler, message, deterCalls, replies } = createHarness([
|
||||||
|
createSocket('socket-1', 'user-one', 'Croissant'),
|
||||||
|
createSocket('socket-2', 'user-two', 'croissant'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await handler(message, ['ban', 'croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(deterCalls, []);
|
||||||
|
assert.match(replies[0].content, /user-one/);
|
||||||
|
assert.match(replies[0].content, /user-two/);
|
||||||
|
assert.match(replies[0].content, /rs deter ban <userId>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mute uses the same exact online nickname preference as ban', async () => {
|
||||||
|
const verifiedUsers = [
|
||||||
|
{ userId: 'old-user', nickname: 'Croissant', cookieUserId: 'old-cookie' },
|
||||||
|
{ userId: 'live-user', nickname: 'Croissant', cookieUserId: 'live-cookie' },
|
||||||
|
];
|
||||||
|
const { handler, message, muteCalls } = createHarness([
|
||||||
|
createSocket('socket-1', 'live-user', 'Croissant'),
|
||||||
|
], verifiedUsers);
|
||||||
|
|
||||||
|
await handler(message, ['mute', 'croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(muteCalls, [{ action: 'mute', selector: 'live-user' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmute returns usable ids for genuinely duplicated online nicknames', async () => {
|
||||||
|
const { handler, message, muteCalls, replies } = createHarness([
|
||||||
|
createSocket('socket-1', 'user-one', 'Croissant'),
|
||||||
|
createSocket('socket-2', 'user-two', 'croissant'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await handler(message, ['unmute', 'Croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(muteCalls, []);
|
||||||
|
assert.match(replies[0].content, /user-one/);
|
||||||
|
assert.match(replies[0].content, /user-two/);
|
||||||
|
assert.match(replies[0].content, /rs deter unmute <userId>/);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user