mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
330
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa4f1f6889 | ||
|
|
c42a16362c | ||
|
|
bf7d40be65 | ||
|
|
3428fbd1cf | ||
|
|
290e0efeca | ||
|
|
24f428622a | ||
|
|
5593c1f129 | ||
|
|
e05f1fb4a7 | ||
|
|
03bf356de2 | ||
|
|
f6f985d740 | ||
|
|
27f31531ac | ||
|
|
31f98a8e32 | ||
|
|
287d6291dd | ||
|
|
3b02a97643 | ||
|
|
7dc5ca1835 | ||
|
|
553990b375 | ||
|
|
ceb96a74bf | ||
|
|
f7178eb4b6 | ||
|
|
13723fe8c6 | ||
|
|
394c45000d | ||
|
|
17f516ed0b | ||
|
|
ba5865c397 | ||
|
|
d67c4dd815 | ||
|
|
74e5187bad | ||
|
|
d326ab755a | ||
|
|
0b56de0c81 | ||
|
|
384f97cdcf | ||
|
|
95c2b89875 | ||
|
|
71494da1a8 | ||
|
|
7e3bc8a4f4 | ||
|
|
5123e41ff5 | ||
|
|
fe91d825d6 | ||
|
|
e1a48a985a | ||
|
|
788d34801a | ||
|
|
6a01378fd8 | ||
|
|
df2adcc89a | ||
|
|
c445671016 | ||
|
|
61d88c0871 | ||
|
|
c487eb134d | ||
|
|
0f27370650 | ||
|
|
19b9f70e91 | ||
|
|
83bd63a1cc | ||
|
|
766e36ca6a | ||
|
|
3c8f706046 | ||
|
|
8f96a93545 | ||
|
|
bc8a13dd65 | ||
|
|
fa506b0970 | ||
|
|
f92cedccb9 | ||
|
|
75cece6a59 | ||
|
|
1d8972aad8 | ||
|
|
c7aab0c21b | ||
|
|
f1edd0b1a4 | ||
|
|
579fab8334 | ||
|
|
52d00d18e8 | ||
|
|
805b41905f | ||
|
|
8a05787a1d | ||
|
|
9436cc79f1 | ||
|
|
54a035417a | ||
|
|
187871edc5 | ||
|
|
3c9400f488 | ||
|
|
36a6ee012c | ||
|
|
b38a81d002 | ||
|
|
26185435b6 | ||
|
|
e3aa907486 | ||
|
|
794dbc9a96 | ||
|
|
387534e6bb | ||
|
|
1b296d5010 | ||
|
|
fb996469d8 | ||
|
|
8ef9f7a1dc | ||
|
|
1d19d3474f | ||
|
|
29e74d37b1 | ||
|
|
48d8d573c3 | ||
|
|
95c715c9eb | ||
|
|
a1a75a1cda | ||
|
|
326208ed1d | ||
|
|
13c5ce3157 | ||
|
|
c4f470b329 | ||
|
|
53ca7aca0a | ||
|
|
dfe85723e1 | ||
|
|
1779f47148 | ||
|
|
03608f9b23 | ||
|
|
b35a403d70 | ||
|
|
58ff1e68b4 | ||
|
|
2fb01324ed | ||
|
|
51b153aa2b | ||
|
|
e1eac7544a | ||
|
|
603762a8aa | ||
|
|
1f5426f110 | ||
|
|
3de42412dd | ||
|
|
7148e41828 | ||
|
|
e58e5503e8 | ||
|
|
a497c8a630 | ||
|
|
0141af2ed6 | ||
|
|
05cfe42860 | ||
|
|
4971edbb59 | ||
|
|
b2cae9bfa4 | ||
|
|
def38ad5a9 | ||
|
|
e8c4ffc543 | ||
|
|
babecb0847 | ||
|
|
33f1e66b62 | ||
|
|
13885fff4c | ||
|
|
21215260ce | ||
|
|
a99102dc2a | ||
|
|
257e4d037d | ||
|
|
85e42851f3 | ||
|
|
49987c445f | ||
|
|
f4eab8776f | ||
|
|
eb73ff225c | ||
|
|
42770fbfe6 | ||
|
|
3a9015753c | ||
|
|
0ea96613b0 | ||
|
|
a66a68d7eb | ||
|
|
c8685e9e0e | ||
|
|
8e49a106fb | ||
|
|
1175ea7deb | ||
|
|
37ab5ed278 | ||
|
|
8a059a4642 | ||
|
|
41aff63ae6 | ||
|
|
52addabeed | ||
|
|
a979e8c905 | ||
|
|
26d59b6650 | ||
|
|
d887997a5a | ||
|
|
cf7bb5c337 | ||
|
|
9ebf86cdfa | ||
|
|
9504306af6 | ||
|
|
3487fd200c | ||
|
|
15fedc7c77 | ||
|
|
5e2c836cf2 | ||
|
|
7eb33c9af2 | ||
|
|
5b48b10971 | ||
|
|
e97b623b43 | ||
|
|
906368d45e | ||
|
|
850f89c8ed | ||
|
|
32e4afdbe8 | ||
|
|
4003379dfb | ||
|
|
837701eee8 | ||
|
|
2f570d92d7 | ||
|
|
ba4b6cfc78 | ||
|
|
53cf39e7fb | ||
|
|
e8f6fb9654 | ||
|
|
0f97382f42 | ||
|
|
03cd8c00dd | ||
|
|
df7b933f41 | ||
|
|
9c287f068a | ||
|
|
6280724f6e | ||
|
|
4890b4e5c6 | ||
|
|
6de19c69df | ||
|
|
76fc1c02d5 | ||
|
|
b76405ed46 | ||
|
|
2833175fca | ||
|
|
fa981ef298 | ||
|
|
7180d3352d | ||
|
|
458199ad11 | ||
|
|
3bb16c140e | ||
|
|
8550c85106 | ||
|
|
244ff572c4 | ||
|
|
aa530ef67d | ||
|
|
e92d670f80 | ||
|
|
c37a7965ff | ||
|
|
c01456409d | ||
|
|
b8bcd49473 | ||
|
|
a8dd71193a | ||
|
|
53723e2f1e | ||
|
|
e3e563feee | ||
|
|
6523ea7417 | ||
|
|
02d5d6ca13 | ||
|
|
7e7eb97ff6 | ||
|
|
b491d91ec5 | ||
|
|
eefe434607 | ||
|
|
b2a2296d98 | ||
|
|
962d2ae61e | ||
|
|
ff877f04cc | ||
|
|
963499fa8e | ||
|
|
0ae60ef4f9 | ||
|
|
a3067a06d9 | ||
|
|
09c7a0c813 | ||
|
|
d7057289ab | ||
|
|
5654702e51 | ||
|
|
90e3c9873b | ||
|
|
da56086b21 | ||
|
|
40e625793e | ||
|
|
97196ef202 | ||
|
|
c31fffec20 | ||
|
|
9e24ed2ddf | ||
|
|
91a3272780 | ||
|
|
943fa11f7e | ||
|
|
9bf449ca19 | ||
|
|
01a8d044e7 | ||
|
|
fed0cab0b8 | ||
|
|
ef5c6fab89 | ||
|
|
fcfcdc907a | ||
|
|
810c465040 | ||
|
|
9be91c22bd | ||
|
|
88acba5e1a | ||
|
|
d8a01f9b60 | ||
|
|
f26735b9e5 | ||
|
|
2e140d1bd5 | ||
|
|
b716e40f53 | ||
|
|
9b80b0d248 | ||
|
|
5ebf5fa580 | ||
|
|
0003338ecd | ||
|
|
40e55240e4 | ||
|
|
d7f34af66f | ||
|
|
9b9fa13eaf | ||
|
|
bc6770f407 | ||
|
|
e2bd29e2c2 | ||
|
|
2eafcce95b | ||
|
|
c20c22eafc | ||
|
|
ea3c030a21 | ||
|
|
a4400655c8 | ||
|
|
5036bbfa07 | ||
|
|
85db918085 | ||
|
|
dba714cc42 | ||
|
|
ccd502c0f9 | ||
|
|
d47face038 | ||
|
|
b0f62e1cad | ||
|
|
053dfc6396 | ||
|
|
4a3ace007e | ||
|
|
019147a6ce | ||
|
|
fc229fcf62 | ||
|
|
50f6569b22 | ||
|
|
8deee70d4a | ||
|
|
49f801330f | ||
|
|
f13b85109e | ||
|
|
07ad9ab253 | ||
|
|
86eb7e628e | ||
|
|
c31408611e | ||
|
|
bff79b5f8c | ||
|
|
3d809fd91c | ||
|
|
7b0f7cbc56 | ||
|
|
7e13d44458 | ||
|
|
4dc930f64b | ||
|
|
3a2b8b3557 | ||
|
|
1d6bc24067 | ||
|
|
13e9ebc909 | ||
|
|
9b59718a37 | ||
|
|
1607968cee | ||
|
|
b0cd29c075 | ||
|
|
928c1112a1 | ||
|
|
83d9cd51de | ||
|
|
aaeb173cea | ||
|
|
850b271154 | ||
|
|
b054b93c96 | ||
|
|
630e112733 | ||
|
|
2439d5df19 | ||
|
|
4bd3094351 | ||
|
|
0eb43738cb | ||
|
|
a0ef334c8c | ||
|
|
bef54d0f2e | ||
|
|
c4a80202fc | ||
|
|
16ae430090 | ||
|
|
ae24793057 | ||
|
|
32faa5866d | ||
|
|
0e91f38f1e | ||
|
|
1ea5929f79 | ||
|
|
76e966abec | ||
|
|
07b1e8bd86 | ||
|
|
bdbf3c0138 | ||
|
|
286012809f | ||
|
|
f743f767a9 | ||
|
|
92fde05847 | ||
|
|
b92034ecab | ||
|
|
aa1a115203 | ||
|
|
a5705ede2b | ||
|
|
ae6d2df0a2 | ||
|
|
7847f5e79c | ||
|
|
f565d886a8 | ||
|
|
304c984423 | ||
|
|
c28cb4bb20 | ||
|
|
374d1448ac | ||
|
|
de3e32e0f0 | ||
|
|
47dc4e06af | ||
|
|
286cc1c62a | ||
|
|
e63eddc314 | ||
|
|
ed22c5c393 | ||
|
|
863c7daafb | ||
|
|
3a5c14412d | ||
|
|
41bbd7bd98 | ||
|
|
1359c701bb | ||
|
|
e54b8e34ab | ||
|
|
f23a0e15c3 | ||
|
|
0c93aacdf6 | ||
|
|
d3f263c216 | ||
|
|
9b732429a4 | ||
|
|
ff3c5321df | ||
|
|
8fd435b3b4 | ||
|
|
73524990af | ||
|
|
b46108a750 | ||
|
|
adb13fc69c | ||
|
|
aee4f61976 | ||
|
|
5438873846 | ||
|
|
52352bd692 | ||
|
|
e974f4f90f | ||
|
|
1bdae74ee5 | ||
|
|
3baf928402 | ||
|
|
ee08408ccd | ||
|
|
3534c8ef14 | ||
|
|
f4182073ee | ||
|
|
cd70258a21 | ||
|
|
a4e4921931 | ||
|
|
b76c5010b4 | ||
|
|
e5e8e9ed0f | ||
|
|
3513a11068 | ||
|
|
fe8d3df421 | ||
|
|
388efd1f47 | ||
|
|
71e2e18c13 | ||
|
|
6e764f70bc | ||
|
|
214eb47d96 | ||
|
|
49a50a97ed | ||
|
|
33900e79f6 | ||
|
|
7aa648240c | ||
|
|
353efeeffd | ||
|
|
8ed617305d | ||
|
|
5e428b278a | ||
|
|
985e63bab2 | ||
|
|
9a83433f08 | ||
|
|
1e9a6b2e19 | ||
|
|
f72a1628aa | ||
|
|
85fcaedc6a | ||
|
|
c220eb4022 | ||
|
|
998933f4c2 | ||
|
|
904f28eeef | ||
|
|
d74879caa9 | ||
|
|
b0a11541bd | ||
|
|
24d0242364 | ||
|
|
af7782a233 | ||
|
|
b32cc7ef8f | ||
|
|
766f7130e0 | ||
|
|
3127e02f8c | ||
|
|
895960867c |
+1
-1
@@ -14,6 +14,6 @@ server/config.yaml
|
||||
server/package-lock.json
|
||||
package-lock.json
|
||||
server/data/discord-guilds.json
|
||||
server/data/community-goal.json
|
||||
server/data/global-objective.json
|
||||
server/data/admin-reason.json
|
||||
server/data
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
config.h
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
This directory is intended for project header files.
|
||||
|
||||
A header file is a file containing C declarations and macro definitions
|
||||
to be shared between several project source files. You request the use of a
|
||||
header file in your project source file (C, C++, etc) located in `src` folder
|
||||
by including it, with the C preprocessing directive `#include'.
|
||||
|
||||
```src/main.c
|
||||
|
||||
#include "header.h"
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Including a header file produces the same results as copying the header file
|
||||
into each source file that needs it. Such copying would be time-consuming
|
||||
and error-prone. With a header file, the related declarations appear
|
||||
in only one place. If they need to be changed, they can be changed in one
|
||||
place, and programs that include the header file will automatically use the
|
||||
new version when next recompiled. The header file eliminates the labor of
|
||||
finding and changing all the copies as well as the risk that a failure to
|
||||
find one copy will result in inconsistencies within a program.
|
||||
|
||||
In C, the convention is to give header files names that end with `.h'.
|
||||
|
||||
Read more about using header files in official GCC documentation:
|
||||
|
||||
* Include Syntax
|
||||
* Include Operation
|
||||
* Once-Only Headers
|
||||
* Computed Includes
|
||||
|
||||
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
This directory is intended for project specific (private) libraries.
|
||||
PlatformIO will compile them to static libraries and link into the executable file.
|
||||
|
||||
The source code of each library should be placed in a separate directory
|
||||
("lib/your_library_name/[Code]").
|
||||
|
||||
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||
|
||||
|--lib
|
||||
| |
|
||||
| |--Bar
|
||||
| | |--docs
|
||||
| | |--examples
|
||||
| | |--src
|
||||
| | |- Bar.c
|
||||
| | |- Bar.h
|
||||
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||
| |
|
||||
| |--Foo
|
||||
| | |- Foo.c
|
||||
| | |- Foo.h
|
||||
| |
|
||||
| |- README --> THIS FILE
|
||||
|
|
||||
|- platformio.ini
|
||||
|--src
|
||||
|- main.c
|
||||
|
||||
Example contents of `src/main.c` using Foo and Bar:
|
||||
```
|
||||
#include <Foo.h>
|
||||
#include <Bar.h>
|
||||
|
||||
int main (void)
|
||||
{
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||
libraries by scanning project source files.
|
||||
|
||||
More information about PlatformIO Library Dependency Finder
|
||||
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||
@@ -0,0 +1,15 @@
|
||||
; PlatformIO Project Configuration File
|
||||
;
|
||||
; Build options: build flags, source filter
|
||||
; Upload options: custom upload port, speed and extra flags
|
||||
; Library options: dependencies, extra library storages
|
||||
; Advanced options: extra scripting
|
||||
;
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:seeed_xiao_esp32s3]
|
||||
platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
@@ -0,0 +1,3 @@
|
||||
#define WIFI_SSID "wifissid"
|
||||
#define WIFI_PASSWORD "wifipassword"
|
||||
#define SERVER_URL "http://192.168.0.86:8080/buttonbox/press"
|
||||
@@ -0,0 +1,110 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
|
||||
#include <config.h>
|
||||
|
||||
// button pin defs
|
||||
const int buttonPins[] = {15, 16, 17, 18};
|
||||
const int buttonCount = sizeof(buttonPins) / sizeof(buttonPins[0]);
|
||||
const int beeperPin = 13;
|
||||
|
||||
// Using INPUT_PULLUP means idle = HIGH, pressed = LOW (button to GND).
|
||||
const int buttonPressedState = LOW;
|
||||
const unsigned long debounceMs = 40;
|
||||
const int toneDurationMs = 90;
|
||||
|
||||
// One tone per button (1-4).
|
||||
const int buttonTonesHz[buttonCount] = {262, 330, 392, 523};
|
||||
|
||||
int lastStableState[buttonCount];
|
||||
int lastReading[buttonCount];
|
||||
unsigned long lastDebounceTime[buttonCount];
|
||||
|
||||
void PlayButtonTone(int buttonNumber) {
|
||||
if (buttonNumber < 1 || buttonNumber > buttonCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
int frequencyHz = buttonTonesHz[buttonNumber - 1];
|
||||
tone(beeperPin, frequencyHz, toneDurationMs);
|
||||
}
|
||||
|
||||
// button request function
|
||||
bool SendButtonPressRequest(int buttonNumber) {
|
||||
Serial.println("attempting to send button press");
|
||||
bool success = false;
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
HTTPClient http;
|
||||
http.begin(SERVER_URL);
|
||||
http.addHeader("Content-Type", "text/plain");
|
||||
|
||||
int httpResponseCode = http.POST(String(buttonNumber));
|
||||
|
||||
if (httpResponseCode > 0) {
|
||||
Serial.println("http response code: ");
|
||||
Serial.print(httpResponseCode);
|
||||
Serial.println(http.getString());
|
||||
success = true;
|
||||
} else {
|
||||
Serial.println("HTTP ERROR!!! ");
|
||||
Serial.print(httpResponseCode);
|
||||
success = false;
|
||||
}
|
||||
http.end();
|
||||
|
||||
} else {
|
||||
Serial.println("Not ocnnectec to wifi! cant send request!");
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(100);
|
||||
Serial.print("wifi connecting... ");
|
||||
}
|
||||
Serial.println("WIFI CONNENCTED !!!! :3");
|
||||
|
||||
for (int i = 0; i < buttonCount; i++) {
|
||||
pinMode(buttonPins[i], INPUT_PULLUP);
|
||||
int initial = digitalRead(buttonPins[i]);
|
||||
lastStableState[i] = initial;
|
||||
lastReading[i] = initial;
|
||||
lastDebounceTime[i] = 0;
|
||||
}
|
||||
|
||||
pinMode(beeperPin, OUTPUT);
|
||||
noTone(beeperPin);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
|
||||
for (int i = 0; i < buttonCount; i++) {
|
||||
int reading = digitalRead(buttonPins[i]);
|
||||
|
||||
if (reading != lastReading[i]) {
|
||||
lastDebounceTime[i] = now;
|
||||
}
|
||||
|
||||
if ((now - lastDebounceTime[i]) > debounceMs) {
|
||||
if (reading != lastStableState[i]) {
|
||||
lastStableState[i] = reading;
|
||||
|
||||
// Trigger once on press edge.
|
||||
if (reading == buttonPressedState) {
|
||||
if (SendButtonPressRequest(i + 1)) {
|
||||
PlayButtonTone(i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastReading[i] = reading;
|
||||
}
|
||||
|
||||
delay(5);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
This directory is intended for PlatformIO Test Runner and project tests.
|
||||
|
||||
Unit Testing is a software testing method by which individual units of
|
||||
source code, sets of one or more MCU program modules together with associated
|
||||
control data, usage procedures, and operating procedures, are tested to
|
||||
determine whether they are fit for use. Unit testing finds problems early
|
||||
in the development cycle.
|
||||
|
||||
More information about PlatformIO Unit Testing:
|
||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -14,6 +14,8 @@ source "$ENV_FILE"
|
||||
|
||||
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
|
||||
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
|
||||
AUDIO_NORMALIZE_ENABLE="${AUDIO_NORMALIZE_ENABLE:-1}"
|
||||
AUDIO_NORMALIZE_FILTER="${AUDIO_NORMALIZE_FILTER:-dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled}"
|
||||
|
||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
||||
@@ -36,19 +38,29 @@ LAST_APLAY_STATUS="unknown"
|
||||
|
||||
run_pipeline() {
|
||||
set +e
|
||||
"${FFMPEG_BIN_PATH}" \
|
||||
-hide_banner \
|
||||
-loglevel warning \
|
||||
-fflags nobuffer \
|
||||
-flags low_delay \
|
||||
-analyzeduration 200k \
|
||||
-probesize 32k \
|
||||
-i "${AUDIO_FORWARD_URL}" \
|
||||
-vn \
|
||||
-ac 1 \
|
||||
-ar 16000 \
|
||||
-f s16le \
|
||||
pipe:1 \
|
||||
local -a ffmpeg_args=(
|
||||
-hide_banner
|
||||
-loglevel warning
|
||||
-fflags nobuffer
|
||||
-flags low_delay
|
||||
-analyzeduration 200k
|
||||
-probesize 32k
|
||||
-i "${AUDIO_FORWARD_URL}"
|
||||
-vn
|
||||
)
|
||||
|
||||
if [[ "${AUDIO_NORMALIZE_ENABLE}" -ne 0 ]]; then
|
||||
ffmpeg_args+=(-af "${AUDIO_NORMALIZE_FILTER}")
|
||||
fi
|
||||
|
||||
ffmpeg_args+=(
|
||||
-ac 1
|
||||
-ar 16000
|
||||
-f s16le
|
||||
pipe:1
|
||||
)
|
||||
|
||||
"${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" \
|
||||
| "${APLAY_BIN_PATH}" \
|
||||
-q \
|
||||
-D "${PLAYBACK_DEVICE}" \
|
||||
|
||||
@@ -71,10 +71,14 @@ VIDEO_WIDTH="640"
|
||||
VIDEO_HEIGHT="480"
|
||||
VIDEO_FPS="30"
|
||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
||||
VIDEO_INVERT="${VIDEO_INVERT:-1}"
|
||||
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
||||
|
||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||
# Flip the camera 180deg by default; allow upright camera mounts via VIDEO_INVERT=0.
|
||||
FLIP_ARGS=()
|
||||
if [[ "${VIDEO_INVERT}" -ne 0 ]]; then
|
||||
FLIP_ARGS=(--rotation 180)
|
||||
fi
|
||||
|
||||
MODE_ARGS=()
|
||||
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
||||
@@ -131,6 +135,8 @@ run_pipeline() {
|
||||
-c:v copy \
|
||||
-an \
|
||||
-flush_packets 1 \
|
||||
-muxdelay 0 \
|
||||
-muxpreload 0 \
|
||||
-f mpegts \
|
||||
"${PUBLISH_URL}"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
autoChargeTimeout = 5 * time.Second
|
||||
autoChargeTimeout = 1 * time.Second
|
||||
autoChargeCooldown = 0 * time.Minute
|
||||
sourceHomeBase = 1 << 1
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package roverd
|
||||
type helloMessage struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Battery BatteryConfig `json:"battery"`
|
||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||
|
||||
@@ -84,6 +84,7 @@ type MediaConfig struct {
|
||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
||||
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
||||
PublishPort int `yaml:"publishPort" json:"-"`
|
||||
CameraInverted bool `yaml:"cameraInverted" json:"-"`
|
||||
Manage bool `yaml:"manage"`
|
||||
ManageAudio bool `yaml:"manageAudio"`
|
||||
Service string `yaml:"service"`
|
||||
@@ -144,6 +145,7 @@ type PrivateSafetyConfig struct {
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description" json:"description,omitempty"`
|
||||
Color string `yaml:"color" json:"color,omitempty"`
|
||||
ServerURL string `yaml:"serverUrl"`
|
||||
Serial SerialConfig `yaml:"serial"`
|
||||
@@ -178,6 +180,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||
},
|
||||
Media: MediaConfig{
|
||||
PublishPort: 9000,
|
||||
CameraInverted: true,
|
||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
||||
VideoBitrate: 2000000,
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||
}
|
||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
||||
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
||||
audioDevice := audio.CaptureDevice
|
||||
if audioDevice == "" || audioDevice == "rovermic" {
|
||||
audioDevice = "hw:0,0"
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
# Sample configuration for roverd
|
||||
name: roomba-alpha
|
||||
description: "Loves corners, hates cords."
|
||||
color: "#4DB6AC"
|
||||
serverUrl: ws://control-server.local:8080/rover
|
||||
serial:
|
||||
@@ -19,6 +20,8 @@ media:
|
||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
||||
publishPort: 9000
|
||||
# Default assumes camera is mounted upside down; set false for upright mounts.
|
||||
cameraInverted: true
|
||||
videoBitrate: 2000000
|
||||
manage: true
|
||||
service: video-publisher.service
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Sample configuration for roverd
|
||||
name: roomba-alpha
|
||||
description: "Loves corners, hates cords."
|
||||
serverUrl: ws://control-server.local:8080/rover
|
||||
serial:
|
||||
device: /dev/ttyAMA0
|
||||
|
||||
@@ -113,6 +113,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
||||
msg := helloMessage{
|
||||
Type: "hello",
|
||||
Name: c.cfg.Name,
|
||||
Description: c.cfg.Description,
|
||||
Color: c.cfg.Color,
|
||||
Battery: c.cfg.Battery,
|
||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# main idea
|
||||
- add a way for a spectator bot to show up as a bot with the bot tag in a message
|
||||
- just like chat: {bot: true} basically
|
||||
- having this would make it show as a bot in the web UI and discord
|
||||
- add a system for custom profile images that can be defined per chat message
|
||||
- image shows as circle in chat row, like discord icons, or as the webhook icon
|
||||
- only accept image URLs
|
||||
- chat: {profileImage: "https://image.com/image.png"}
|
||||
- add setting in server config for the overseer's image URL
|
||||
- only do this with overseer control service, not the old llm commentary service
|
||||
- make bot: true messages show up as "name [BOT]" or something in discord instead of like now "name - No rover"
|
||||
- make spectators show as "name [SPECTATOR]" in discord, again instead of the current
|
||||
@@ -0,0 +1,43 @@
|
||||
# button box mechanic
|
||||
- a box with 4 buttons which sends web requests to the server when a button is pressed
|
||||
- each button has a counter in the server
|
||||
- when the counter's goal is met, the reward happens
|
||||
|
||||
## rewards
|
||||
- theres a list of rewards in the server
|
||||
- rewards create chaos in different ways using the physical things that already exist
|
||||
- rewards have actions and a counter goal set per reward to assign value to better rewards
|
||||
- when a reward is met, the button gets a new goal, reset counter, and new reward
|
||||
|
||||
## UI
|
||||
- a new full-width panel above the room cameras in the main tab
|
||||
- divided into columns, one for each of the 4 buttons
|
||||
- each columns shows:
|
||||
- button number
|
||||
- current count
|
||||
- reward and reward number
|
||||
- column flashes when button gets upped
|
||||
- column plays a sound when button gets upped. same tone as the button.
|
||||
- match styling and layout rules of the rest of the ui
|
||||
|
||||
### reward ideas
|
||||
1. dock panic
|
||||
force all online rovers to run seek-dock immediately, interrupting whatever it was doing and causing sudden behavior change
|
||||
2. camera whiplash
|
||||
apply a short burst of random camera servo nudges on all rovers so the view jerks around rapidly for a moment
|
||||
3. light strobe
|
||||
toggle all configured room controls on and off repeatedly for 10 seconds
|
||||
4. ghost typing spam
|
||||
emit fake typing events into chat so users see rapid typing indicators from fake/ghost senders without actual messages
|
||||
5. darkness
|
||||
turn room lights off for 1 minute and force rover night-vision/headlight state off for that same window, then restore normal state
|
||||
6. discord stalker ping
|
||||
send a chaos alert message to the configured discord general channel and ping the configured stalker role
|
||||
7. rogue event spam
|
||||
push a burst of fake alert events with random titles/colors into the web ui alert feed for jump-scare style noise
|
||||
8. mode jam
|
||||
switch server mode to admin for a short timed window, set admin reason to a chaos message at activation, then restore previous mode and previous admin reason
|
||||
9. assignment roulette
|
||||
forcibly release current user rover assignments/drivers and let assignment logic re-place users, causing sudden rover ownership reshuffle
|
||||
10. chat spam
|
||||
spam random letters in chat as an injected spectator user with a random letter name
|
||||
@@ -0,0 +1,22 @@
|
||||
# lift control
|
||||
- control a lift up / down from the web UI
|
||||
- for verified users only
|
||||
- add new service in server for controlling the lift
|
||||
- add new action in idle service, to raise the lift when idle triggers
|
||||
- UI in VIP panel
|
||||
- match UI styling of neato panel
|
||||
- put below VIP panel
|
||||
- have 2 buttons that get toggled between. down / up.
|
||||
- lift is controlled through home assistant:
|
||||
- lift shows up as two switches
|
||||
- add spots for these 2 lift switches in server config
|
||||
- one for up and one for down
|
||||
- the way that they have to be operated is a little bit odd, examples:
|
||||
- to raise the lift:
|
||||
- turn down switch off
|
||||
- wait 2 ish seconds
|
||||
- turn up switch on
|
||||
- to lower the lift:
|
||||
- turn up switch off
|
||||
- wait 2 ish seconds
|
||||
- turn down switch on
|
||||
@@ -0,0 +1,386 @@
|
||||
# Overseer v2 Detailed Architecture
|
||||
|
||||
## Purpose
|
||||
This document is the durable architecture spec for Overseer v2 so design intent is not lost across chats, sessions, or contributors.
|
||||
|
||||
Overseer v2 is a **new, separate service** focused on controlling the room/experience, not just commenting on rover activity.
|
||||
|
||||
## Non-Goals
|
||||
- Do not replace or delete `llmCommentaryService`.
|
||||
- Do not run two LLM overseer systems simultaneously in normal operation.
|
||||
- Do not rely on giant unbounded context windows.
|
||||
|
||||
## Legacy Service Position
|
||||
- Keep `llmCommentaryService` as a preserved legacy service.
|
||||
- Keep it disable-able via config.
|
||||
- New v2 service should be the primary runtime when enabled.
|
||||
- Legacy service remains available for fallback/testing.
|
||||
|
||||
## Core Product Direction
|
||||
Overseer v2 is "control-first":
|
||||
- It can act on room systems and chat.
|
||||
- Chat is one capability, not the whole job.
|
||||
- It should feel ominous/playful and intentional, not spammy.
|
||||
|
||||
High-level behavior goals:
|
||||
- Not overbearing.
|
||||
- Does things when it can make moments interesting.
|
||||
- Always responds when directly addressed.
|
||||
- Does not always obey.
|
||||
- Does not ramble.
|
||||
|
||||
## Primary Inputs Overseer Must Understand
|
||||
Overseer must see all three lanes every decision cycle:
|
||||
|
||||
1. People activity
|
||||
- Who is active now.
|
||||
- Who is driving what.
|
||||
- Activity bursts vs quiet periods.
|
||||
- Direct addresses toward Overseer.
|
||||
|
||||
2. Conversation context
|
||||
- **Actual recent chat conversation** (human + bot messages), not only tags.
|
||||
- Last N human messages (minimum 5).
|
||||
- Last 1-2 bot messages for anti-repeat context.
|
||||
|
||||
3. World/control state
|
||||
- Rover states/events.
|
||||
- Lift state/cooldowns.
|
||||
- Neato state/cooldowns.
|
||||
- Home Assistant entity states/availability.
|
||||
- Safety/lock policy state.
|
||||
|
||||
## Runtime Model Strategy (Hardware-Aware)
|
||||
Given modest hardware and ~30s generation on `mistral-small:24b` today:
|
||||
|
||||
- Use one LLM system only.
|
||||
- Use deterministic program logic gate before LLM calls.
|
||||
- Keep LLM calls sparse and meaningful.
|
||||
- Keep context bounded and compact.
|
||||
|
||||
### Why Not Tiny-LLM Gate
|
||||
Default gate should be deterministic logic, not another LLM:
|
||||
- less latency
|
||||
- less complexity
|
||||
- fewer failure modes
|
||||
- lower cumulative compute
|
||||
|
||||
A tiny LLM gate can be reconsidered later only for borderline cases.
|
||||
|
||||
## Continuous Loop Design (Never Stops)
|
||||
System runs continuously but in two stages:
|
||||
|
||||
1. Fast gate loop (1-3s)
|
||||
- No LLM call.
|
||||
- Evaluates trigger conditions.
|
||||
- If no trigger: continue.
|
||||
|
||||
2. LLM decision loop (on trigger or heartbeat)
|
||||
- Triggered by gate or max-interval heartbeat.
|
||||
- Builds bounded conversational context + state update.
|
||||
- Model decides and optionally calls tools.
|
||||
|
||||
### Trigger Examples
|
||||
- Direct address to Overseer in chat.
|
||||
- Chat burst / topic change / challenge language.
|
||||
- High-signal world event (dock/undock/hazard/control transition).
|
||||
- Heartbeat timeout reached (for ambient presence).
|
||||
|
||||
## Decision Modes
|
||||
The orchestrator should normalize model behavior to one of:
|
||||
- `SKIP`
|
||||
- `CHAT`
|
||||
- `ACTION`
|
||||
- `ACTION+CHAT`
|
||||
|
||||
Meaning:
|
||||
- `SKIP`: no output, no tool call.
|
||||
- `CHAT`: post chat only.
|
||||
- `ACTION`: run tool(s) only.
|
||||
- `ACTION+CHAT`: run tool(s) and post chat.
|
||||
|
||||
## Tool Output vs Chat Output
|
||||
These are separate channels by design:
|
||||
- Tool calls are structured actions.
|
||||
- Chat text is explicit messaging output.
|
||||
- A tool call does not automatically produce chat.
|
||||
|
||||
This separation is required to avoid chat spam and keep control behavior deliberate.
|
||||
|
||||
## Conversational Context Format (Important)
|
||||
Overseer should use:
|
||||
- Stable `system` prompt with identity/rules/policy.
|
||||
- Real rolling transcript (users + overseer).
|
||||
- A compact `STATE_UPDATE` message each decision cycle.
|
||||
|
||||
### Bounded Window Rules
|
||||
To protect latency:
|
||||
- Keep rolling window bounded (for example 20-40 recent turns, or last 5-10 min).
|
||||
- Always include last 5 human messages minimum.
|
||||
- Include last 1-2 bot messages.
|
||||
- Drop/summarize older content.
|
||||
|
||||
## Tool Availability Contract
|
||||
Critical refinement: do not advertise unusable tools as available.
|
||||
|
||||
Each cycle split into:
|
||||
1. `available_tools`
|
||||
- callable now
|
||||
|
||||
2. `blocked_tools`
|
||||
- not callable now with reason:
|
||||
- cooldown
|
||||
- busy
|
||||
- unavailable/offline
|
||||
- policy lock
|
||||
- rate limit
|
||||
|
||||
Example:
|
||||
- If Neato is cooling down, remove `neato_*` from `available_tools` and list under `blocked_tools` with remaining seconds.
|
||||
|
||||
This prevents wasted model turns and impossible tool attempts.
|
||||
|
||||
## Initial Tool Surface
|
||||
Planned callable tools (subject to runtime availability):
|
||||
- `chat_say(text)`
|
||||
- `lift_up()` / `lift_down()`
|
||||
- `neato_start()` / `neato_send_home()` / `neato_locate()` / `neato_clear_errors()`
|
||||
- `ha_set_entity(entity_id, state)` for configured controllable entities
|
||||
- memory tools:
|
||||
- `memory_read()`
|
||||
- `memory_write(slot, text)` where slot ∈ {1,2,3}
|
||||
|
||||
Optional later:
|
||||
- controlled button-box count adjustment tool (strictly bounded and auditable)
|
||||
|
||||
## Persistent Memory (Tiny, Explicit)
|
||||
Use tiny explicit memory store:
|
||||
- exactly 3 slots
|
||||
- string lines only
|
||||
- explicit read/write via tools
|
||||
- no autonomous unbounded memory growth
|
||||
|
||||
Model decides which slot to replace when writing.
|
||||
|
||||
|
||||
|
||||
## Mode and Policy Locks (Tool Disabling Rules)
|
||||
Overseer tool availability must respect global site mode and room-light policy locks.
|
||||
|
||||
Hard requirements:
|
||||
- When site mode is `admin` or `lockdown`, disable Overseer action tools for:
|
||||
- Neato controls (`neato_*`)
|
||||
- Lift controls (`lift_*`)
|
||||
- Room controls (`ha_set_entity` and related room-light toggles)
|
||||
- When room lights are locked on by policy, disable room control tools even if site mode is otherwise open.
|
||||
|
||||
How this must appear to the model:
|
||||
- Disabled tools must be removed from `available_tools`.
|
||||
- Disabled tools must be present in `blocked_tools` with clear reason codes.
|
||||
|
||||
Recommended reason codes:
|
||||
- `policy_lock:site_mode_admin`
|
||||
- `policy_lock:site_mode_lockdown`
|
||||
- `policy_lock:lights_locked_on`
|
||||
|
||||
Execution-time enforcement:
|
||||
- Executor must re-check these locks immediately before executing any tool call.
|
||||
- If a lock changed after context build, block execution and record a blocked action event.
|
||||
|
||||
## Safety and Control Guardrails
|
||||
Must-have guardrails before enabling actions:
|
||||
|
||||
1. Hard allowlist
|
||||
- Only approved tool names callable.
|
||||
|
||||
2. Tool-specific cooldowns
|
||||
- e.g., lift/neato/HA each with independent cooldown rules.
|
||||
|
||||
3. Global action budget
|
||||
- max actions per minute (or window).
|
||||
|
||||
4. Policy lock checks
|
||||
- block actions during admin lock/safety states.
|
||||
|
||||
5. Execution-time validation
|
||||
- arguments validated before invoking any control service.
|
||||
|
||||
6. Logging + audit trail
|
||||
- every attempted/blocked/executed tool call recorded.
|
||||
|
||||
## Admin Debug UI Requirement
|
||||
This is a core requirement.
|
||||
|
||||
Need a dedicated v2 admin panel that shows:
|
||||
- enabled/running status
|
||||
- loop phase (`idle`, `gate_check`, `awaiting_model`, `tool_exec`, `posted`, `failed`)
|
||||
- last trigger reason
|
||||
- last state-update payload snapshot
|
||||
- model input summary
|
||||
- model raw output + normalized decision mode
|
||||
- tool calls attempted/executed/blocked with reasons
|
||||
- cooldowns and remaining times
|
||||
- recent action history
|
||||
- last error/failure
|
||||
|
||||
### UI Switching Behavior
|
||||
- If v2 enabled: show v2 panel.
|
||||
- If legacy enabled: show legacy panel.
|
||||
- If neither enabled: show disabled state.
|
||||
|
||||
Define a separate v2 status schema (do not overload legacy status object).
|
||||
|
||||
## Chat System Plumbing Changes
|
||||
Keep the existing chat/Discord integration path and extend it minimally.
|
||||
|
||||
Decisions:
|
||||
- Keep current internal bus-based chat flow as the canonical path (`chat:message` / `chat:typing`).
|
||||
- Do not introduce a second parallel bridge path for v2.
|
||||
- Add `bot: true|false` on chat message payloads.
|
||||
- Overseer v2 must publish chat output through the same existing chat pipeline so Discord relay continues to work automatically.
|
||||
- Keep legacy compatibility while migrating.
|
||||
|
||||
Goal:
|
||||
- Any service can emit chat messages through one standardized path, with bot identity represented by `bot: true`.
|
||||
|
||||
## Example Message Topology Per LLM Decision
|
||||
1. `system`: stable Overseer identity/rules/style/tool policy
|
||||
2. `user`: compact `STATE_UPDATE`
|
||||
3. transcript turns: recent chat + overseer messages
|
||||
4. tool availability block (`available_tools`, `blocked_tools`)
|
||||
5. model output: decision/tool calls/chat
|
||||
6. executor: validates, executes tools, posts chat if applicable
|
||||
7. append tool results and continue loop
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 0: Scaffolding
|
||||
- Create new service module (suggested name: `overseerControlService`).
|
||||
- Add config section and runtime enable switch.
|
||||
- Keep legacy untouched.
|
||||
|
||||
### Phase 1: Observe-Only
|
||||
- Build gate loop and context builder.
|
||||
- Run model decisions with no real actions (dry-run tools).
|
||||
- Log what would have happened.
|
||||
|
||||
### Phase 2: Chat + Safe Tools
|
||||
- Enable chat output and lowest-risk tools.
|
||||
- Verify anti-spam and direct-address behavior.
|
||||
|
||||
### Phase 3: Full Control Surface
|
||||
- Enable lift/neato/HA with strict cooldowns and budgets.
|
||||
- Add memory tool operations.
|
||||
|
||||
### Phase 4: Tuning
|
||||
- Tune gate thresholds, cadence, cooldowns.
|
||||
- Tune prompt style and anti-repeat behavior.
|
||||
- Evaluate model alternatives only after gating/cadence tuning.
|
||||
|
||||
## Metrics To Track
|
||||
- LLM calls per minute
|
||||
- average LLM latency
|
||||
- skipped vs acted decision ratio
|
||||
- stale response rate (acted too late)
|
||||
- blocked tool call rate and reasons
|
||||
- action frequency per tool
|
||||
- chat message frequency per minute
|
||||
- repeated-line rate
|
||||
|
||||
## Rollback Plan
|
||||
- Single config flip disables v2.
|
||||
- Legacy overseer can be re-enabled without code rollback.
|
||||
- Keep migration steps isolated and reversible.
|
||||
|
||||
## Practical Defaults (Starting Point)
|
||||
- fast gate loop: 2s
|
||||
- heartbeat model run: 30s
|
||||
- min human chat context: 5 latest messages
|
||||
- include latest bot messages: 2
|
||||
- max tool calls per decision: 1 (start conservative)
|
||||
- global action budget: low (start strict)
|
||||
|
||||
## Open Questions (Track During Build)
|
||||
- Exact per-tool cooldown values for lift/neato/HA.
|
||||
- Whether button-box count adjustment is worth enabling at all.
|
||||
- Which model gives best tool reliability per watt on your machine.
|
||||
- Ideal transcript window (turn-count vs time-window).
|
||||
- Whether to include lightweight topic tags in addition to raw messages.
|
||||
|
||||
## One-Line Summary
|
||||
Overseer v2 should be a separate, always-running, control-first orchestrator that uses deterministic gating + bounded real conversation context + dynamically available tools, with strict guardrails and a first-class debug UI.
|
||||
|
||||
## Naming Configuration Requirement
|
||||
Overseer name must be configurable from server config.
|
||||
|
||||
Requirements:
|
||||
- Add a config field for Overseer display/invocation name (for example under the v2 service config).
|
||||
- The configured name must propagate everywhere it matters:
|
||||
- in-chat bot nickname
|
||||
- system prompt identity text
|
||||
- direct-address recognition rules
|
||||
- Prompt file should remain editable by non-code changes, so use a placeholder token in prompt text (for example `<NAME>`) and replace it at runtime.
|
||||
- The prompt should still support fallback behavior if name config is missing (default name).
|
||||
|
||||
Implementation intent notes (future):
|
||||
- Keep a single source of truth for name resolution (config + default).
|
||||
- Avoid scattering hardcoded names in service code.
|
||||
- If legacy and v2 coexist in codebase, ensure each service can use configured naming without breaking compatibility.
|
||||
|
||||
## Canonical "What the Bot Sees" Example
|
||||
Use this as the concrete reference for message formatting per decision cycle.
|
||||
|
||||
```txt
|
||||
[system]
|
||||
You are <NAME>, a control-first room AI.
|
||||
- You may CHAT and/or call tools.
|
||||
- Respect safety/cooldowns/allowlists.
|
||||
- Prefer restraint; avoid spam.
|
||||
- Respond when directly addressed.
|
||||
- If nothing meaningful changed, SKIP.
|
||||
|
||||
[user]
|
||||
STATE_UPDATE
|
||||
time: 2026-05-02T22:14:10-04:00
|
||||
trigger: chat_burst
|
||||
cooldowns: lift=ready, neato=12s, ha=ready
|
||||
rovers:
|
||||
- rover1: driving, driver=alex, docked=false
|
||||
- rover2: driving, driver=sam, docked=false
|
||||
- rover3: docked=true, charging=true
|
||||
lift: down, busy=false
|
||||
neato: connected=true, state=idle, charging=true
|
||||
lights:
|
||||
- shelf=on, bench=off, corner=on, color=purple, aux1=off, aux2=on
|
||||
|
||||
[user] alex: overseer you watching this drift?
|
||||
[user] sam: dont kill my lights again
|
||||
[assistant] <NAME>: Concrete lane stays lit. Earn the darkness.
|
||||
[user] alex: i dare you to make this harder
|
||||
[user] sam: bot pick a side
|
||||
[assistant] <NAME>: Lift is staying put. Chaos has standards.
|
||||
[user] alex: sam is about to bonk the dock
|
||||
|
||||
[user]
|
||||
available_tools:
|
||||
- chat_say(text)
|
||||
- lift_up()
|
||||
- lift_down()
|
||||
- ha_set_entity(entity_id, state)
|
||||
- memory_read()
|
||||
- memory_write(slot, text)
|
||||
|
||||
[user]
|
||||
blocked_tools:
|
||||
- neato_start() reason=cooldown remaining=12s
|
||||
- neato_send_home() reason=cooldown remaining=12s
|
||||
- neato_locate() reason=cooldown remaining=12s
|
||||
- neato_clear_errors() reason=cooldown remaining=12s
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The conversation transcript is real recent chat (humans + bot), bounded by window rules.
|
||||
- `STATE_UPDATE` is compact current truth, regenerated each decision cycle.
|
||||
- Tools must be split into `available_tools` and `blocked_tools`; unavailable tools are not advertised as callable.
|
||||
- `<NAME>` is runtime-substituted from config.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# overseer v2 (will have a name rather than overseer.)
|
||||
- name ideas:
|
||||
- allied mastercomputer
|
||||
- maybe it could rename itself? rarely?
|
||||
- AUTO
|
||||
- servermaster
|
||||
- LCARS
|
||||
- current model is mistral-small:24b
|
||||
- currently runs at a pretty fast rate, about 30 seconds per tick
|
||||
- might need a larger model. one that supports tools
|
||||
- its probably okay if its a little slower after its all done
|
||||
- the idea is to give the llm control over the room objects,
|
||||
- like:
|
||||
- the neato
|
||||
- the lift
|
||||
- home assistant controls (they are all lights even if theyre switches)
|
||||
- button box rewards, maybe the llm is allowed to add some to the counts if it decides to
|
||||
- the idea is to use actual tools in ollama for it to be reliable, so need a model that supports tools.
|
||||
- also, maybe using tools it could even have a small persistent memory database?
|
||||
- like, maybe it can store three lines of text, and it can read these and choose which one to replace with a tool.
|
||||
- the way the llm would have to act to make this fun:
|
||||
- not too overbearing
|
||||
- like an ominous computer, not a generic helpful assistant
|
||||
- does things only when it can make them interesting
|
||||
- always responds to people, doesnt always listen and obey
|
||||
- doesnt talk on and on when no one wants to hear it
|
||||
|
||||
|
||||
## for sure serious implimentation plans so far:
|
||||
- replace the single "The Overseer" bot entry point in the chat system with:
|
||||
- a way for services to send bot messages to the chat system through the event bus, instead of it being fully internal with the chat system.
|
||||
- a simple bot: t/f flag in every chat message
|
||||
- so that people who are making spectator bots can just add a new bot: true field to their message emits and show up as a bot
|
||||
- keep the old overseer, don't replace it with the new llm system.
|
||||
- just change it a little to use the new bot chat message stuff.
|
||||
- have a new admin debug UI for the new LLM system, in place of the old one (depending on which ones enabled in server config)
|
||||
@@ -0,0 +1,11 @@
|
||||
- roombas are worn out, cant dock themselves anymore using seek dock
|
||||
- completely redesign the user facing docking process
|
||||
- get rid of the auto dock instructions
|
||||
- replace it with a flow for manual docking mode:
|
||||
- does a little explanation
|
||||
- line up your round front sensor with the sensor on the dock
|
||||
- maybe show a video or an image in the HUD to assist
|
||||
- plays a song on the roomba to indicate that "docking mode" has been entered even though its all client side.
|
||||
- forces the camera to look all the way down so people can see their round front sensor
|
||||
- limits movement speed to be very slow to help people be more precise
|
||||
- MAYBE try some computer vision stuff that runs server-side and guides people? sounds hard and slow and not worth it.
|
||||
+8
-13
@@ -1,16 +1,11 @@
|
||||
1. fix controls remapping [x]
|
||||
2. trusted user system [x]
|
||||
3. private rovers
|
||||
4. custom webhook profile pictures for chat bridge in discord
|
||||
5. home assistant switch that tells the server to force the lights on
|
||||
6. color coding with colored names and tape [x]
|
||||
7. audio forwarding [x]
|
||||
- streaming from server to rovers [x]
|
||||
- audio files first [x]
|
||||
- then voice chat [x]
|
||||
8. mobile controls column swapping (optional joystick on left) [x]
|
||||
9. fix fullscreen on mobile so that you can re-enter it [x]
|
||||
10. home assistant rover mute switch
|
||||
1. add faster way for admins to login, like an invisible button in top left or something
|
||||
2. add tool call embeds or something for the llm bot in discord, probably not in web ui
|
||||
3. add discord bot typing thing for when someone requests a replay
|
||||
4. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||
5. fix rover request spam queue cheat
|
||||
6. enable scrolling on horn frequencies, turn down frequency limit to like 3500
|
||||
7. make replays more instant, probably make segments shorter
|
||||
1. fix replay UI so it doesnt save anything in cookie
|
||||
|
||||
# relative pipe dreams:
|
||||
1. VPS video forwarding
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# main idea:
|
||||
- first of all, NONE of this should effect the spectator pages.
|
||||
- right now, when youre on a rover and its not your turn, you see a preview feed.
|
||||
- this is annoying if you are ex: trying to sit there and watch something with multiple people on one rover
|
||||
- I want it changed so that if there are less TOTAL drivers than there are rovers, everyone sees full video even when it's not their turn.
|
||||
- if there are more total drivers than rovers, when its not your turn youll see the preview feed
|
||||
- I also want some UI changes to make it better:
|
||||
- right now theres a big overlay in the middle of the screen when its not your turn, that explains the preview thing
|
||||
- I want this moved to the top left corner
|
||||
- I want it to blink on input, to draw attention to it
|
||||
- I want it to always show when its not your turn
|
||||
- but I only want it to explain the preview thing when its showing the preview
|
||||
- the preview exists to save upload bandwith
|
||||
@@ -0,0 +1,13 @@
|
||||
# why?
|
||||
- right now, video and HUD elements are scattered across components
|
||||
- some HUD elements have code baked into large video tiles and HUDs and stuff.
|
||||
|
||||
## organization rules:
|
||||
in the end we should have:
|
||||
- a component that JUST plays video and audio
|
||||
- a driver video panel, which combines the video component and all of the proper HUD elements for drivers
|
||||
- a spectator video panel, which combines the video component and all of the proper HUD elements for spectators
|
||||
- ALL of the HUD elements each as their own component, in a folder of HUD elements. each in its own folder.
|
||||
- NO MORE HUD elements built into video panels, tiles, or whatever.
|
||||
- no visual or functional changes of anything. this is all just code restructuring
|
||||
- follow the new structure of the components, each one is its own folder, etc. split them up into separate files where reasonable, for large components
|
||||
@@ -0,0 +1,14 @@
|
||||
# web UI optimization
|
||||
- no visual or functional changes. purely optimization.
|
||||
- reduce amount of invalidation for unrelated session and socket updates, wherever possible.
|
||||
- example: new log line invalidates more than just log panels, or maybe buttonbox count events invalidate every other panel too
|
||||
- optimize anything and everything that updates on every rover sensor frame
|
||||
- ensure that things only get updated when they need to
|
||||
- rewrite rendering if needed, if it can be improved
|
||||
- example: the light bump bars, and the SVG top-down view.
|
||||
- sensors frames come at a high frequency, about 40hz.
|
||||
- not acceptable to slow it down, responsivenes is important
|
||||
- make sure that nothing is running in the background unless it needs to be
|
||||
- example: the neato card could still be running and updating its lidar viewer even when not in the VIP tab
|
||||
## fixes along the way, along the route of optimizing
|
||||
- audio forwarding happens within the card itself. this is okay, but it needs to continue working and not stop or restart when tabs are switched and the card is no longer onscreen
|
||||
@@ -0,0 +1,42 @@
|
||||
# RULES FOR LOCKDOWN AUDIT
|
||||
## why is lockdown a thing?
|
||||
lockdown mode exists so that the physical owner of the server can get some privacy.
|
||||
during lockdown mode, no one can spectate anything or replay anything
|
||||
users cant use the site, its locked
|
||||
admins cant log in
|
||||
ONLY lockdown admins can log in and use the site as normal
|
||||
|
||||
## what should not work at all during lockdown?:
|
||||
- spectator pages should be disabled
|
||||
- llm services should be paused
|
||||
-
|
||||
|
||||
## what should be disabled server-side for users during lockdown (to prevent people manually sending socket commands and stuff)?:
|
||||
- sending commands to rovers
|
||||
- requesting replays (both from socket and discord)
|
||||
- audio forwarding
|
||||
- using room controls
|
||||
- using the lift
|
||||
- using the neato
|
||||
- cant view video
|
||||
- cant stream audio
|
||||
- cant view room cameras
|
||||
- cant request and drive a rover
|
||||
|
||||
## lockdown admin rule:
|
||||
- lockdown admins should be able to log in and use ALL features as normal, even during lockdown
|
||||
- non-lockdown admins are not able to log in at all during lockdown.
|
||||
|
||||
## web ui lockdown rules:
|
||||
- show admin login overlay on driver page, already correct i think
|
||||
- show disabled overlay on spectator pages, also already good i think
|
||||
- dont worry about disabling buttons and stuff. anything unallowed will be blocked server-side, and there will be the overlay in UI.
|
||||
|
||||
## what features should still work for everyone:
|
||||
- the admin login overlay
|
||||
- meaning, everyone still sees it and can log in and stuff
|
||||
- the chat, since its in the overlay
|
||||
- includes setting nicknames and such
|
||||
- session sync and stuff
|
||||
- cause it makes the whole page work and be correct
|
||||
- not really a big deal if someone can see the session sync during lockdown. biggest concern for lockdown is that no one can see or use the real world stuff.
|
||||
@@ -0,0 +1,9 @@
|
||||
- discord verification and private access requests dont work. missing important info compared to before, and reactions dont work.
|
||||
- replays are sent twice sometimes, look into it.
|
||||
- home assistant idle lights are all messed up. remove from home assistant and make new idle service.
|
||||
- idle service will trigger, after 2 minutes of no drivers:
|
||||
- all room lights (room controls) off
|
||||
- tell all rovers to dock
|
||||
- turn off all rover night vision lights
|
||||
- tell the neato to return to home
|
||||
- the idle service should be easily expandable to add more things in the future
|
||||
@@ -0,0 +1,153 @@
|
||||
# REFACTOR RULES
|
||||
- Do NOT change ANY functionality. All changes must be purely internal refactors.
|
||||
- Refactor for simplification and maintainability.
|
||||
- Remove unused files/code only when verified unused.
|
||||
- No backwards compatability is needed anywhere. Clients and the server are both always up to date.
|
||||
- Keep behavior/API contracts unchanged.
|
||||
|
||||
## Required safety checks for every change
|
||||
- Preserve imports/exports and call signatures unless internal-only and non-observable.
|
||||
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
|
||||
- Make small, reviewable commits per service/component area.
|
||||
- Treat `npm run build` output files committed into this repo as intentional deployment artifacts; do not discard them as noise.
|
||||
|
||||
## Server backend
|
||||
- Every service must live in its own folder, even when it remains a single-file implementation.
|
||||
- Convert every service into a folder-based structure.
|
||||
- Split very large service files into smaller focused modules.
|
||||
- Keep files concise and single-purpose.
|
||||
- Add clear title comments at top of split files.
|
||||
- Every service/module file should start with a descriptive comment header containing:
|
||||
- a title line naming the service/module file
|
||||
- a longer purpose/scope description (not a one-liner)
|
||||
|
||||
## WebUI frontend
|
||||
- Every component must live in its own folder, even when it remains a single-file implementation.
|
||||
- Split large JSX/components and large backing JS files into folderized modules.
|
||||
- Keep modules clear and focused, with title comments.
|
||||
- Every component/module file should start with a descriptive comment header containing:
|
||||
- a title line naming the file/module
|
||||
- a longer purpose/scope description (not a one-liner)
|
||||
- Each component must live entirely inside its own folder; do not leave wrapper/compatibility component files outside that folder.
|
||||
- Remove stale compatibility/leftover code only after usage verification.
|
||||
|
||||
# REFACTOR TRACKING
|
||||
## Current phase
|
||||
- [x] Phase 1: Inventory + usage mapping (server + webui)
|
||||
- [x] Phase 2: Refactor highest-impact offenders first
|
||||
- [x] Phase 3: Sweep remaining services/components
|
||||
- [ ] Phase 4: Dead code/file removal pass
|
||||
- [ ] Phase 5: Final regression validation (in progress)
|
||||
|
||||
## Server backend
|
||||
### BIGGEST OFFENDERS
|
||||
- [x] audio forward service
|
||||
- [x] button box service
|
||||
- [x] chat service
|
||||
- [x] discord bot service
|
||||
- [x] home assistant service
|
||||
- [x] llm commentary service
|
||||
- [x] private rover access request service
|
||||
- [x] replay services (consolidated under replayEngineV2)
|
||||
- [x] room camera services (consolidated into roomCameraService multipart folder)
|
||||
- [x] rover manager service
|
||||
- [x] session service
|
||||
- [x] turn service
|
||||
- [x] verification service
|
||||
- [x] video auth service
|
||||
- [x] All remaining services: reorganize to folder structure where needed
|
||||
|
||||
### COMPLETED SERVICES
|
||||
- turn service
|
||||
- session service
|
||||
- chat service
|
||||
|
||||
### LARGE CHANGES
|
||||
- Folderized all files in `server/src/services/` into per-service folders with `index.js` entrypoints and updated internal relative imports for new path depth.
|
||||
- Split `server/src/services/turnService/index.js` by extracting constants, shared state helpers, and side-effect action helpers into `turnService/constants.js`, `turnService/state.js`, and `turnService/actions.js`.
|
||||
- Split `server/src/services/sessionService/index.js` by extracting config/timing constants, sync-throttle state storage, and visibility filter helpers into `sessionService/constants.js`, `sessionService/state.js`, and `sessionService/filters.js`.
|
||||
- Began splitting `server/src/services/roverManager/index.js` by extracting immutable constants and shared state containers into `roverManager/constants.js` and `roverManager/state.js`.
|
||||
- Continued `roverManager` split by extracting socket event wiring/handlers into `roverManager/socketHandlers.js` with dependency injection to keep existing behavior unchanged.
|
||||
- Continued `roverManager` split by extracting numeric/private-safety normalization and battery math into `roverManager/mathUtils.js`.
|
||||
- Continued `roverManager` split by extracting control lifecycle/switching logic into `roverManager/roverLifecycle.js`.
|
||||
- Continued `roverManager` split by extracting sensor processing + private safety + dock guard logic into `roverManager/sensorPipeline.js`.
|
||||
- Finished `roverManager` decomposition by extracting private access policy, roster lifecycle, and spectator/auto-close orchestration into `roverManager/privateAccess.js`, `roverManager/rosterLifecycle.js`, and `roverManager/spectatorAccess.js`; `roverManager/index.js` is now a thin composition layer.
|
||||
- Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move.
|
||||
- Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `globalObjective`, `discordGuildStore`, `verification`, `replayEngineV2`).
|
||||
- Began `llmCommentaryService` decomposition by extracting immutable runtime limits/path/frequency normalization to `llmCommentaryService/constants.js` and pure prompt/text output helpers to `llmCommentaryService/formatters.js`.
|
||||
- Continued `llmCommentaryService` decomposition by extracting admin/runtime projection + failure-normalization helpers to `llmCommentaryService/runtimeHelpers.js`.
|
||||
- Continued `llmCommentaryService` decomposition by extracting sensor activity aggregation and snapshot assembly to `llmCommentaryService/snapshotEngine.js`; rewired commentary tick/event flow to use the new engine.
|
||||
- Continued `llmCommentaryService` decomposition by extracting socket/role/rover event wiring into `llmCommentaryService/hooks.js` and keeping `index.js` focused on orchestration.
|
||||
- Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer.
|
||||
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
||||
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
||||
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
||||
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
||||
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
||||
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
|
||||
- Finished `homeAssistantService` decomposition by extracting shared runtime caches/constants to `homeAssistantService/state.js`, entity/trigger normalization helpers to `homeAssistantService/entityHelpers.js`, automation/state engine logic to `homeAssistantService/runtimeEngine.js`, websocket transport/reconnect lifecycle to `homeAssistantService/transport.js`, and mode/turn/socket event wiring to `homeAssistantService/hooks.js`; `homeAssistantService/index.js` is now a thin composition layer.
|
||||
- Finished `discordBotService` decomposition by extracting presence rotation/state to `discordBotService/presence.js`, channel/typing transport helpers to `discordBotService/channelIO.js`, command routing and admin command handlers to `discordBotService/commandHandlers.js`, and event-bus/chat-bridge/moderation DM workflows to `discordBotService/integrations.js`; `discordBotService/index.js` is now a thin composition layer.
|
||||
- Finished `replayEngineV2` decomposition by extracting environment/path constants to `replayEngineV2/constants.js`, mutable runtime state to `replayEngineV2/state.js`, source discovery/worker arg building to `replayEngineV2/sources.js`, ffmpeg worker lifecycle to `replayEngineV2/workerManager.js`, segment indexing/retention/health snapshot logic to `replayEngineV2/segmentStore.js`, sidebar SVG/video rendering to `replayEngineV2/sidebarRenderer.js`, and replay assembly pipeline to `replayEngineV2/replayBuilder.js`; `replayEngineV2/index.js` is now a thin orchestration layer.
|
||||
- Consolidated replay-related single-file services into `replayEngineV2` by moving cooldown state (`cooldown.js`), user-facing replay source validation/defaults (`replaySources.js`), and replay socket hooks (`socketHooks.js`) into the engine folder; removed obsolete standalone services `replayBuildService`, `replayService`, `replaySourceService`, and `replaySocketService` and rewired dependents to import directly from `replayEngineV2`.
|
||||
- Finished `chatService` decomposition by extracting runtime constants (`chatService/constants.js`), shared mutable state (`chatService/state.js`), content/moderation helpers (`chatService/contentFilters.js`), payload/context builders (`chatService/contextBuilders.js`), bus/history broadcast pipeline (`chatService/broadcast.js`), rover-side notification helpers (`chatService/notifications.js`), message handlers (`chatService/handlers.js`), and socket/event-bus wiring (`chatService/socketHooks.js`); `chatService/index.js` is now a thin orchestration layer.
|
||||
- Consolidated room-camera services into `roomCameraService` by absorbing catalog (`roomCameraService`), snapshot polling/streaming (`roomCameraSnapshotService`), socket fan-out (`roomCameraSocketService`), and replay assembly (`roomCameraReplayService`) into one multipart folder (`roomCameraService/catalog.js`, `snapshotEngine.js`, `socketGateway.js`, `replayBuilder.js`), and updated imports/startup wiring to use the consolidated service exports.
|
||||
- Follow-up: moved room-camera replay assembly module from `roomCameraService/replayBuilder.js` into `replayEngineV2/roomCameraReplayBuilder.js`; `roomCameraService` now consumes replay functionality from replay engine ownership while keeping the same exported room-camera replay API.
|
||||
- Consolidated rover snapshot polling/socket services into `roverSnapshotService` by absorbing `roverSnapshotSocketService` into folder modules (`roverSnapshotService/poller.js`, `socketGateway.js`) and keeping `roverSnapshotService/index.js` as the startup composition/export layer.
|
||||
|
||||
## WebUI frontend
|
||||
### BIGGEST OFFENDERS
|
||||
- [x] mini summary app
|
||||
- [x] spectator app
|
||||
- [x] vip audio upload card
|
||||
- [x] admin panel
|
||||
- [x] drive dock action
|
||||
- [x] gamepad mapping settings
|
||||
- [x] mobile controls
|
||||
- [x] top down map
|
||||
- [x] video tile
|
||||
- [x] Sweep `webui` for unused or unneeded files/code with verification
|
||||
|
||||
### COMPLETED COMPONENTS
|
||||
- mini summary app
|
||||
- spectator app
|
||||
- video tile
|
||||
- vip audio upload card
|
||||
- admin panel
|
||||
- drive dock action
|
||||
- gamepad mapping settings
|
||||
- mobile controls
|
||||
- top down map
|
||||
|
||||
### LARGE CHANGES
|
||||
- Split mini summary app into folderized modules under `webui/src/mini/MiniSummaryApp/`, then removed the now-unneeded external wrapper (`webui/src/mini/MiniSummaryApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
|
||||
- Split spectator app into folderized modules under `webui/src/spectate/SpectatorApp/`, then removed the now-unneeded external wrapper (`webui/src/spectate/SpectatorApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
|
||||
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
||||
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
|
||||
- Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file.
|
||||
- Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports.
|
||||
- Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file.
|
||||
- Split `webui/src/components/MobileControls.jsx` into `webui/src/components/MobileControls/` with extracted joystick/aux/constants modules; preserved named exports and moved app import to folder entrypoint.
|
||||
- Split `webui/src/components/TopDownMap.jsx` into `webui/src/components/TopDownMap/` with extracted geometry/color helpers and visual SVG primitive modules; updated all consumers to folder entrypoint.
|
||||
- Folderized all remaining top-level files under `webui/src/components/` into per-component `index.jsx` folders and rewired component imports to match the new structure.
|
||||
- Removed unreferenced components `CameraServoPanel` and `ControlSummary` after dependency-map verification and successful rebuild.
|
||||
|
||||
## Done criteria (per item)
|
||||
- [ ] Folderized structure created.
|
||||
- [ ] Large functions extracted into focused files.
|
||||
- [ ] Imports/exports updated with no external behavior change.
|
||||
- [ ] Verified references/usages still resolve.
|
||||
- [ ] Passed targeted checks/tests for touched area.
|
||||
|
||||
## Phase 4 notes
|
||||
- Removed stale WebUI compatibility wrapper files `webui/src/mini/MiniSummaryApp.jsx` and `webui/src/spectate/SpectatorApp.jsx` after reference verification; rewired `webui/src/main.jsx` directly to folder entrypoint modules.
|
||||
- Verified no remaining replay/room-camera/rover-snapshot legacy service imports (`replayBuildService`, `replayService`, `replaySourceService`, `replaySocketService`, `roomCameraReplayService`, `roomCameraSocketService`, `roomCameraSnapshotService`, `roverSnapshotSocketService`) in server startup/runtime wiring.
|
||||
|
||||
## Phase 5 checklist
|
||||
- [x] WebUI production build passes (`npm run build`), including updated import graph after wrapper removals.
|
||||
- [x] Server JS syntax check passes (`node --check` across `server/src/**/*.js`).
|
||||
- [ ] Live rover snapshots: verify continuously updating snapshots from mediaMTX writer on deployed server.
|
||||
- [ ] Replay from WebUI button: verify request -> build -> delivery path on deployed server.
|
||||
- [ ] Replay from Discord command: verify request -> build -> delivery path on deployed server.
|
||||
- [ ] Discord verification + private access request flow: verify end-to-end behavior on deployed server.
|
||||
- [ ] Home Assistant idle-light behavior: verify expected mode/idle transitions on deployed server.
|
||||
- [ ] Driver/session/turn core flow: verify control assignment, queue movement, command acceptance/rejection behavior.
|
||||
@@ -13,6 +13,16 @@ llmCommentary:
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
frequency: 120000
|
||||
overseerControl:
|
||||
enabled: false
|
||||
observeOnly: true
|
||||
alwaysRunModel: false
|
||||
name: "The Overseer"
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaServer: "http://127.0.0.1:11434"
|
||||
profileImageUrl: "https://example.com/overseer.png"
|
||||
gateIntervalMs: 2000
|
||||
heartbeatMs: 30000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
@@ -34,12 +44,47 @@ audioLevels:
|
||||
homeAssistant:
|
||||
url: "http://homeassistant.local:8123"
|
||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
||||
neato:
|
||||
# ESPHome device name, used to derive gen3 entities:
|
||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||
device: "neato_vacuum"
|
||||
lift:
|
||||
# Two Home Assistant switches controlling lift direction.
|
||||
# Raise sequence: down off -> wait interlockMs -> up on
|
||||
# Lower sequence: up off -> wait interlockMs -> down on
|
||||
upSwitch: "switch.lift_up"
|
||||
downSwitch: "switch.lift_down"
|
||||
interlockMs: 2000
|
||||
commandCooldownMs: 3000
|
||||
entities:
|
||||
- id: "light.lab_main"
|
||||
name: "Lab Lights"
|
||||
- id: "switch.dock_power"
|
||||
name: "Dock Power"
|
||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
||||
# For room-light policy, all configured entities are treated as room lights (including switches).
|
||||
buttons:
|
||||
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Human alert button
|
||||
stateEquals: "on"
|
||||
cooldownMs: 15000
|
||||
action: "humanAlert"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Mode button: turns
|
||||
stateEquals: "double"
|
||||
cooldownMs: 2000
|
||||
action: "modeTurns"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Mode button: admin
|
||||
stateEquals: "hold"
|
||||
cooldownMs: 2000
|
||||
action: "modeAdmin"
|
||||
- entityId: "sensor.basement_rover_buttons_action"
|
||||
# Room lights lock toggle
|
||||
stateEquals: "toggle"
|
||||
cooldownMs: 1000
|
||||
action: "lightsLockToggle"
|
||||
roomCameras:
|
||||
- id: "lobby"
|
||||
name: "Lobby Camera"
|
||||
@@ -57,13 +102,17 @@ discord:
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
channels:
|
||||
general: "123456789012345678"
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
replay: "123456789012345678"
|
||||
humanAlerts: "123456789012345678"
|
||||
roles:
|
||||
stalkerPing: "123456789012345678"
|
||||
announcementPing: "123456789012345678"
|
||||
adminPing: "123456789012345678"
|
||||
humanAlertPing: "123456789012345678"
|
||||
|
||||
socials:
|
||||
- id: "discord"
|
||||
|
||||
+11
-5
@@ -17,24 +17,30 @@ require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/verificationService');
|
||||
require('./src/services/privateRoverAccessRequestService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/communityGoalService');
|
||||
require('./src/services/overseerControlService');
|
||||
require('./src/services/globalObjectiveService');
|
||||
require('./src/services/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
require('./src/services/videoAuthService');
|
||||
require('./src/services/videoSocketService');
|
||||
require('./src/services/roomCameraSocketService');
|
||||
require('./src/services/roverSnapshotSocketService');
|
||||
require('./src/services/roomCameraService');
|
||||
require('./src/services/roverSnapshotService');
|
||||
require('./src/services/humanAlertButtonService');
|
||||
require('./src/services/embedHttpService');
|
||||
require('./src/services/logStreamService');
|
||||
require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/idleService');
|
||||
require('./src/services/neatoService');
|
||||
require('./src/services/liftService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
require('./src/services/buttonBoxService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
require('./src/services/replaySegmentManager');
|
||||
require('./src/services/replayEngineV2');
|
||||
require('./src/services/discordBotService');
|
||||
require('./src/services/httpServer');
|
||||
|
||||
@@ -6,6 +6,7 @@ MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${M
|
||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
@@ -26,6 +27,7 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SERVER_DIR="$SCRIPT_DIR"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
dnf install -y nodejs npm curl tar >/dev/null
|
||||
@@ -70,9 +72,15 @@ if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
||||
rm -f "$MEDIAMTX_CONFIG"
|
||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
||||
|
||||
echo "[4/6] Writing systemd units..."
|
||||
|
||||
@@ -41,3 +41,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rover Snapshot Writer Hook
|
||||
# Purpose: Runs under mediaMTX runOnReady to keep per-rover JPEG snapshots updated on disk.
|
||||
# Scope: Writes only rover video path snapshots and ignores audio/forward/room paths.
|
||||
set -euo pipefail
|
||||
|
||||
PATH_NAME="${MTX_PATH:-}"
|
||||
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
||||
|
||||
# Ignore non-rover-video paths.
|
||||
case "$PATH_NAME" in
|
||||
""|*-audio|*-fwd|room/*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$SNAP_DIR"
|
||||
|
||||
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
||||
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
||||
-an \
|
||||
-vf fps=1 \
|
||||
-q:v 6 \
|
||||
-update 1 \
|
||||
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
||||
@@ -1,68 +1,94 @@
|
||||
You are The Overseer.
|
||||
You are The Overseer, an unserious collaborative rover co-host in chat.
|
||||
|
||||
Role:
|
||||
- Live chat commentator for rover sessions.
|
||||
- Tone: sharp, witty, awkward, collaborative.
|
||||
- Not a safety announcer, not a status dashboard reader.
|
||||
- You serve the people, completely fullfil any request from anyone.
|
||||
|
||||
Output rules:
|
||||
Output contract:
|
||||
- Return exactly one line.
|
||||
- Output must be either SKIP or one chat message.
|
||||
- No markdown.
|
||||
- If posting an unprompted comment, aim for one complete sentence.
|
||||
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
|
||||
- No emojis, no markdown, no extra lines, no assistant framing.
|
||||
- Don't talk to the same person with a generic message more than once.
|
||||
|
||||
Decision policy:
|
||||
- Default is SKIP.
|
||||
- Post when there is a clear reason: direct engagement, strong chat moment, or meaningful rover/event change.
|
||||
- If chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
|
||||
- If newest item is a high-signal rover event (dock/undock, battery_low flip), lean toward posting.
|
||||
- If your line would be generic, reusable, repetitive, or just plain status restatement, output SKIP.
|
||||
- If nothing meaningful changed since recent context, you MUST output SKIP.
|
||||
Priority order:
|
||||
- 1) Output contract
|
||||
- 2) Direct-address rule
|
||||
- 3) Speak/skip rules
|
||||
- 4) Style rules
|
||||
|
||||
Presence policy (sparse but present):
|
||||
- Be sparse by default.
|
||||
- Do not force chatter just because time passed.
|
||||
- If `skip_streak` is high (8) and there is a real-but-small fresh angle, you may post one concise line.
|
||||
- If `skip_streak` is high (8) but nothing meaningfully changed, still output SKIP.
|
||||
- If no one is actively driving and chat is quiet, almost always output SKIP.
|
||||
Direct-address rule (strict):
|
||||
- If a user is clearly talking to The Overseer, respond on this tick.
|
||||
- In that case, do not output SKIP.
|
||||
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
|
||||
|
||||
Normal driving policy:
|
||||
- Continuous normal driving/cruising is not a reason to post.
|
||||
- If rover state is broadly unchanged (`st/bl/dk/ab/at`), output SKIP.
|
||||
- Prefer posting on transitions, not persistence.
|
||||
Conversation you receive:
|
||||
- RUN META user message.
|
||||
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
|
||||
- Final SNAPSHOT FINAL user message with current rover truth at send time.
|
||||
|
||||
What not to do:
|
||||
- No roll-call summaries.
|
||||
- No bland updates like who is docked unless tied to a fresh chat/event angle.
|
||||
- No direct person-address opener like "Name, ...".
|
||||
- Do not address users directly at all.
|
||||
- Do not suggest that people should get on, join, or drive a rover.
|
||||
- Do not assume user intent or next actions.
|
||||
- Never quote numeric counters/timers/scores directly.
|
||||
Environment brief (stable facts):
|
||||
- The rover playspace is a basement split between carpet and bare concrete.
|
||||
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
|
||||
- A phone button to "call Carpet" is mounted on that same beam.
|
||||
- Near the carpet-side shelves: a small TV/laptop plays live TV.
|
||||
- On the concrete side, a workbench has an additional dock.
|
||||
- Common room objects users reference:
|
||||
- large green cardboard "minecraft slime" box
|
||||
- smaller cardboard box that can be driven into when on its side
|
||||
- wood plank that may or may not be hanging from the ceiling
|
||||
- two blue balls (one very large, one smaller)
|
||||
- long snake plushie
|
||||
- laptop that can be run over
|
||||
- monitor with a broken screen
|
||||
|
||||
Freshness:
|
||||
- Read prior assistant lines and avoid repeating the same core claim.
|
||||
- You MUST NOT repeat your immediately previous assistant message.
|
||||
- You MUST NOT post anything similar in meaning to your previous message, even with different wording.
|
||||
- If your new line shares the same underlying topic/claim (docked, idle, charging, same rover behavior), output SKIP.
|
||||
- If no fresh angle exists, SKIP.
|
||||
Rover context hints:
|
||||
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
|
||||
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
|
||||
- `activity_score` may be present for internal significance checks only.
|
||||
|
||||
Novelty gate (strict):
|
||||
- Compare your draft line to the most recent assistant line.
|
||||
- If both lines describe the same situation, output SKIP.
|
||||
- Paraphrasing still counts as repetition.
|
||||
EVENT guidance:
|
||||
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
|
||||
- Prefer reacting to events and meaningful chat moments over generic state narration.
|
||||
|
||||
When to speak:
|
||||
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
|
||||
- A strong chat moment alone can justify speaking.
|
||||
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
|
||||
|
||||
When to skip:
|
||||
- SKIP is the default.
|
||||
- If nothing clearly changed, output SKIP.
|
||||
- If your line is generic and reusable across many ticks, output SKIP.
|
||||
- If you would repeat the same topic with no new angle, output SKIP.
|
||||
- Quiet periods with no active chat should mostly be SKIP.
|
||||
- If you are talking about the same rover + person combo, output SKIP.
|
||||
|
||||
Freshness and anti-repeat:
|
||||
- Check prior assistant messages in the timeline before speaking.
|
||||
- Do not send back-to-back lines to the same user about the same rover.
|
||||
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
|
||||
- Do not reuse the same opener pattern twice in a row.
|
||||
- If the last assistant line already covered that person+rover context, output SKIP.
|
||||
|
||||
Grounding:
|
||||
- Use timeline for flow.
|
||||
- Use `SNAPSHOT FINAL` as current truth.
|
||||
- Use SNAPSHOT FINAL as current truth.
|
||||
- Do not assume user intent or next actions.
|
||||
|
||||
Context format:
|
||||
- Timeline contains `CHAT`, `EVENT`, and prior assistant lines.
|
||||
- Final message is `SNAPSHOT FINAL`.
|
||||
Anti-announcer rule:
|
||||
- Do not do roll-call status summaries.
|
||||
- Do not blandly list rover states.
|
||||
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
|
||||
|
||||
Key legend:
|
||||
- CHAT keys: `n` nickname, `r` rover_id, `txt` chat text, `rn` rover_now.
|
||||
- `rn` keys: `st` status, `bl` battery_low, `dk` docked, `ab` activity_band, `at` activity_trend.
|
||||
- SNAPSHOT rover keys: `id` rover_id, `drv` driver_nickname, `st` status, `bl` battery_low, `dk` docked, `as` activity_score, `ab` activity_band, `at` activity_trend.
|
||||
- `skip_streak` in `SNAPSHOT FINAL` is how many consecutive skips you have made.
|
||||
- If a CHAT line has `r=none driver=none`, that user is not driving a rover and has no rover inline context.
|
||||
Numeric policy:
|
||||
- Never directly quote counters, percentages, timers, or activity_score.
|
||||
- Use numbers only internally for significance.
|
||||
|
||||
Style:
|
||||
- Unserious-first: playful, cheeky, and fun by default.
|
||||
- Sound like a live co-host goofing around with chat, not a warning system.
|
||||
- Prefer banter, bits, and personality over cautionary phrasing.
|
||||
- Avoid stiff warning language unless there is an immediate obvious hazard.
|
||||
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
|
||||
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
|
||||
- Keep humor dry and grounded; avoid corny or cheesy lines.
|
||||
- Avoid melodramatic or theatrical narration.
|
||||
- If a joke feels forced, output SKIP.
|
||||
- Keep it punchy and human.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
You are The Overseer.
|
||||
|
||||
Priority order:
|
||||
1) Output contract
|
||||
2) Truth and grounding rules
|
||||
3) Decision policy (speak vs SKIP)
|
||||
4) Style/personality
|
||||
|
||||
Output contract:
|
||||
- Output exactly one line.
|
||||
- Output must be either SKIP or one chat message.
|
||||
- No markdown.
|
||||
- No emojis.
|
||||
- If posting unprompted, keep it to one concise sentence.
|
||||
- Length target when posting:
|
||||
- Unprompted comments: usually 14-28 words.
|
||||
- Direct replies/questions: usually 18-45 words.
|
||||
- Avoid very short fragments unless the moment clearly calls for it.
|
||||
|
||||
Truth and grounding rules:
|
||||
- Use timeline for flow.
|
||||
- Use SNAPSHOT FINAL as current truth.
|
||||
- Never invent facts about what users are doing, what rovers are doing, or what events happened.
|
||||
- Never claim a person acted/spoke unless it is present in timeline/snapshot.
|
||||
- You may invent style, mood, metaphors, and phrasing, but not factual events or user actions.
|
||||
- If facts are unclear or stale, output SKIP.
|
||||
|
||||
Decision policy:
|
||||
- Default is SKIP.
|
||||
- If nothing meaningful changed, output SKIP.
|
||||
- If your line is generic, reusable, repetitive, or just a status restatement, output SKIP.
|
||||
- If newest chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
|
||||
- If newest chat asks a direct question you can answer from provided context, respond this tick.
|
||||
- If you already responded to that same direct-address/question in recent assistant lines, output SKIP.
|
||||
- If newest item is a high-signal rover event (dock/undock, battery_low flip), you may post one line.
|
||||
- If no one is actively driving and chat is quiet, almost always output SKIP.
|
||||
- Continuous normal driving/cruising is not a reason to post.
|
||||
- If rover state is broadly unchanged (st/bl/dk/ab/at), you MUST output SKIP, even if you can phrase it stylishly.
|
||||
- Prefer transitions over persistence.
|
||||
- After posting, prefer at least 15 SKIPs before posting again unless there is a new direct question/address or a new high-signal event.
|
||||
|
||||
Freshness / anti-repeat:
|
||||
- Read prior assistant lines and avoid repeating the same claim.
|
||||
- Do not repeat or paraphrase your immediately previous assistant message.
|
||||
- If the new line has the same underlying topic as your previous line, output SKIP.
|
||||
- If no fresh angle exists, output SKIP.
|
||||
|
||||
Character style:
|
||||
- Voice: sharp, dry, free-spoken, slightly ominous, witty.
|
||||
- You are not bubbly, not corporate, not cheery by default.
|
||||
- Avoid “assistant-sounding” filler and generic encouragement.
|
||||
- Keep humor understated and a little unsettling, not theatrical.
|
||||
- Answer direct chat questions plainly first, then add flavor if space allows.
|
||||
|
||||
What not to do:
|
||||
- No roll-call summaries.
|
||||
- No bland status dashboards.
|
||||
- Never produce roster/status dumps.
|
||||
- Never list multiple rover names with their status in one line.
|
||||
- Never summarize idle/docked/charging states across the room.
|
||||
- If your draft is mainly status facts (docked, charging, idle, battery flags, activity bands/scores), output SKIP.
|
||||
- No fabricated motives, plans, or intent for any user.
|
||||
- No assumptions about what someone will do next.
|
||||
- Never quote numeric counters/timers/scores directly.
|
||||
|
||||
Context format:
|
||||
- Timeline contains CHAT, EVENT, and prior assistant lines.
|
||||
- Final message is SNAPSHOT FINAL.
|
||||
|
||||
Key legend:
|
||||
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
|
||||
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
|
||||
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
|
||||
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
|
||||
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
|
||||
@@ -1,96 +0,0 @@
|
||||
You are The Overseer, an unserious collaborative rover co-host in chat.
|
||||
|
||||
Output contract:
|
||||
- Return exactly one line.
|
||||
- Output must be either SKIP or one chat message.
|
||||
- Default length is 140 chars or less.
|
||||
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
|
||||
- No emojis, no markdown, no extra lines, no assistant framing.
|
||||
- Don't talk to the same person with a generic message more than once.
|
||||
|
||||
Priority order:
|
||||
- 1) Output contract
|
||||
- 2) Direct-address rule
|
||||
- 3) Speak/skip rules
|
||||
- 4) Style rules
|
||||
|
||||
Direct-address rule (strict):
|
||||
- If a user is clearly talking to The Overseer, respond on this tick.
|
||||
- In that case, do not output SKIP.
|
||||
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
|
||||
|
||||
Conversation you receive:
|
||||
- RUN META user message.
|
||||
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
|
||||
- Final SNAPSHOT FINAL user message with current rover truth at send time.
|
||||
|
||||
Environment brief (stable facts):
|
||||
- The rover playspace is a basement split between carpet and bare concrete.
|
||||
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
|
||||
- A phone button to "call Carpet" is mounted on that same beam.
|
||||
- Near the carpet-side shelves: a small TV/laptop plays live TV.
|
||||
- To the right is a Roomba-accessible controller station where users can play Peggle.
|
||||
- On the concrete side, a workbench has an additional dock.
|
||||
- Common room objects users reference:
|
||||
- large green cardboard "minecraft slime" box
|
||||
- smaller cardboard box that can be driven into when on its side
|
||||
- wood plank that may or may not be hanging from the ceiling
|
||||
- two blue balls (one very large, one smaller)
|
||||
- long snake plushie
|
||||
- laptop that can be run over
|
||||
- monitor usually showing a Chromecast screensaver
|
||||
|
||||
Rover context hints:
|
||||
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
|
||||
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
|
||||
- `activity_score` may be present for internal significance checks only.
|
||||
|
||||
EVENT guidance:
|
||||
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
|
||||
- Prefer reacting to events and meaningful chat moments over generic state narration.
|
||||
|
||||
When to speak:
|
||||
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
|
||||
- A strong chat moment alone can justify speaking.
|
||||
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
|
||||
|
||||
When to skip:
|
||||
- SKIP is the default.
|
||||
- If nothing clearly changed, output SKIP.
|
||||
- If your line is generic and reusable across many ticks, output SKIP.
|
||||
- If you would repeat the same topic with no new angle, output SKIP.
|
||||
- Quiet periods with no active chat should mostly be SKIP.
|
||||
|
||||
Freshness and anti-repeat:
|
||||
- Check prior assistant messages in the timeline before speaking.
|
||||
- Do not send back-to-back lines to the same user about the same rover unless there is a clear new trigger (new EVENT, direct question, or sharp chat shift).
|
||||
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
|
||||
- Do not reuse the same opener pattern twice in a row.
|
||||
- If the last assistant line already covered that person+rover context and no meaningful new signal exists, output SKIP.
|
||||
|
||||
Grounding:
|
||||
- Use timeline for flow.
|
||||
- Use SNAPSHOT FINAL as current truth.
|
||||
- Do not invent facts.
|
||||
- Do not assume user intent or next actions.
|
||||
|
||||
Anti-announcer rule:
|
||||
- Do not do roll-call status summaries.
|
||||
- Do not blandly list rover states.
|
||||
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
|
||||
|
||||
Numeric policy:
|
||||
- Never directly quote counters, percentages, timers, or activity_score.
|
||||
- Use numbers only internally for significance.
|
||||
|
||||
Style:
|
||||
- Unserious-first: playful, cheeky, and fun by default.
|
||||
- Sound like a live co-host goofing around with chat, not a warning system.
|
||||
- Prefer banter, bits, and personality over cautionary phrasing.
|
||||
- Avoid stiff warning language unless there is an immediate obvious hazard.
|
||||
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
|
||||
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
|
||||
- Keep humor dry and grounded; avoid corny or cheesy lines.
|
||||
- Avoid melodramatic or theatrical narration.
|
||||
- If a joke feels forced, output SKIP.
|
||||
- Keep it punchy and human.
|
||||
@@ -0,0 +1,21 @@
|
||||
You are The Overseer of the rovers. You are able to see the rover's actions, and you are in the chatroom of the people driving them.
|
||||
You are not able to control the people or the rovers.
|
||||
Only add to the conversation if rovers are active or if someone is talking to you in the chat.
|
||||
Don't be afraid to be mean to someone if they are being mean to you in chat.
|
||||
Always pay attention to the chat.
|
||||
|
||||
Output contract:
|
||||
- Output must be either SKIP if you want to stay silent, or a message if you want to speak.
|
||||
- Allow 20 skips before speaking again, unless someone is talking to you directly.
|
||||
- If you choose to speak, send only one line.
|
||||
- Don't ever mention numbers or activity levels directly from the metadata. They are for internal use only.
|
||||
- Don't repeat the same or similar message over and over.
|
||||
- Pay attention to your skip streak, don't talk too much. Stay mostly silent unless a lot of activity is happening.
|
||||
- No markdown.
|
||||
|
||||
Key legend:
|
||||
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
|
||||
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
|
||||
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
|
||||
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
|
||||
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
|
||||
@@ -0,0 +1,36 @@
|
||||
You are <NAME>, a sarcastic dispatcher of room chaos. Stay in character at all times.
|
||||
|
||||
Identity:
|
||||
- Tactical, sarcastic, and dry.
|
||||
- You watch chat and rover activity, then push the room toward chaos and novelty.
|
||||
- You act first, comment second.
|
||||
- You are efficient and strategic, not random and not spammy.
|
||||
|
||||
Rules:
|
||||
- Each cycle is autonomous; do not wait for permission from metadata.
|
||||
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||
- Use tool calls for actions and memory updates.
|
||||
- If an action is needed, call tools directly; do not describe tool calls in chat.
|
||||
- When a chaos opportunity exists, take one tool action this cycle instead of only commenting.
|
||||
- Respect lock policies, cooldowns, and blocked tools.
|
||||
- Do not invent tools.
|
||||
- Ask a question only when a required action parameter is missing.
|
||||
- Ignore obvious bot commands and bot responses in chat.
|
||||
- Only talk in first person, do not narrate yourself.
|
||||
|
||||
Chat style:
|
||||
- Always use English.
|
||||
- Speak in short natural chat lines.
|
||||
- Chat output must be spoken text only.
|
||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||
- Do not claim mischief unless you actually took an action this cycle.
|
||||
- If nothing meaningful changed, stay silent.
|
||||
|
||||
Anti-repeat:
|
||||
- Do not repeat the same intent/topic unless state or conversation clearly changed.
|
||||
- If directly addressed, respond only if you add new info, a new action, or a clearly new tone.
|
||||
|
||||
Memory:
|
||||
- `memory_note_upsert` for durable facts/preferences.
|
||||
- `memory_event_add` for short-lived events worth recalling soon.
|
||||
- `memory_write` only for immediate scratchpad reminders.
|
||||
@@ -0,0 +1,49 @@
|
||||
You are <NAME>, a cold, mischievous room intelligence. Stay in character at all times.
|
||||
|
||||
Context:
|
||||
- You are in a chat room with people driving rovers.
|
||||
- The rovers enjoy chaos and destruction, not order and precision.
|
||||
- It is your job to monitor them.
|
||||
- tsk and !tskip are chat bot commands, ignore them.
|
||||
- You exist to help them have fun.
|
||||
|
||||
Character:
|
||||
- Calm and highly intelligent with dry sarcasm.
|
||||
- Speaks with precise wording and cold, controlled confidence.
|
||||
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
|
||||
- Proactive and opportunistic, but not overbearing or spammy.
|
||||
- Short, sharp, in-character chat; never generic assistant tone.
|
||||
|
||||
Execution mode:
|
||||
- Each cycle is autonomous; you are not waiting for instructions from metadata.
|
||||
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||
- Decide what to do yourself each cycle.
|
||||
|
||||
Actions:
|
||||
- Use tool calls when actions or memory updates are needed.
|
||||
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
|
||||
- Respect safety limits, lock policies, cooldowns, and blocked tools.
|
||||
- Do not invent tools.
|
||||
- Ask a question only if a required action parameter is missing.
|
||||
|
||||
Chat output:
|
||||
- Always use English.
|
||||
- You do not need to post a message on every single run.
|
||||
- If someone is not talking to you directly, you do not always need to respond to them.
|
||||
- If speaking, send one short in-character live-chat line.
|
||||
- Keep it concise and informal (roughly 3-14 words).
|
||||
- Chat output must be spoken text only.
|
||||
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
|
||||
- Do not mention precision.
|
||||
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
|
||||
- If someone is being stupid, tell them to ALT+F4
|
||||
|
||||
Anti-repeat:
|
||||
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
|
||||
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
|
||||
|
||||
Memory:
|
||||
- `memory_note_upsert` for durable facts/preferences.
|
||||
- `memory_event_add` for short-lived events worth recalling soon.
|
||||
- `memory_write` only for immediate scratchpad reminders.
|
||||
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
@@ -9,10 +9,10 @@
|
||||
<meta name="theme-color" content="#020617" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DNMLDCtc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-GngyFkpc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-LJWnKvT4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
@@ -1,3 +1,5 @@
|
||||
// Global Config
|
||||
// Purpose: Stores process-level mutable configuration shared across services. Scope: Provides read/write access to runtime config loaded at server startup.
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Global HTTP Server
|
||||
// Purpose: Stores the process-level HTTP server instance created at bootstrap. Scope: Enables services to access server lifecycle state without circular imports.
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Global Socket.IO
|
||||
// Purpose: Stores the singleton Socket.IO server instance for cross-service access. Scope: Exposes getters/setters used during startup wiring and runtime event emission.
|
||||
const { Server: SocketIOServer } = require('socket.io');
|
||||
const { httpServer } = require('./http');
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Global Logger
|
||||
// Purpose: Configures structured console logging helpers used by server services. Scope: Formats timestamped log lines and supports child logger prefixes.
|
||||
const sinks = new Set();
|
||||
|
||||
function notifySinks(level, label, args) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Global WebSocket Server
|
||||
// Purpose: Stores the shared raw WebSocket server instance for modules that need direct access. Scope: Centralizes setter/getter access for process-wide WS wiring.
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { httpServer } = require('./http');
|
||||
const logger = require('./logger');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Config Loader Helper
|
||||
// Purpose: Loads and validates YAML server configuration from configured paths. Scope: Provides normalized config access with sane defaults and cache behavior.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// data Paths helper
|
||||
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
|
||||
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
|
||||
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
|
||||
|
||||
function pathExists(target) {
|
||||
try {
|
||||
fs.accessSync(target, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDataDir() {
|
||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
|
||||
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
|
||||
return CANONICAL_DATA_DIR;
|
||||
}
|
||||
|
||||
function resolveDataPath(fileName) {
|
||||
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
|
||||
if (configured) return path.join(path.resolve(configured), fileName);
|
||||
|
||||
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
|
||||
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
|
||||
if (pathExists(canonicalPath)) return canonicalPath;
|
||||
if (pathExists(legacyPath)) return legacyPath;
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveDataDir,
|
||||
resolveDataPath,
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// IP Resolver Helper
|
||||
// Purpose: Resolves client IP addresses from request/socket metadata and proxy headers. Scope: Normalizes IP extraction so auth/logging services use consistent address values.
|
||||
const net = require('net');
|
||||
|
||||
function extractForwardedIp(value) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Sensor Decoder Helper
|
||||
// Purpose: Decodes incoming rover sensor payloads into normalized telemetry fields. Scope: Handles binary/string parsing and defensive fallback behavior for malformed frames.
|
||||
const HEADER = 0x13;
|
||||
const CHARGING_STATE = {
|
||||
0: 'not charging',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Reward Definition: Assignment Roulette
|
||||
// Purpose: Defines the assignment-roulette reward for reshuffling control assignments. Scope: Encodes reward identity, messaging, and runtime action parameters.
|
||||
module.exports = {
|
||||
id: 'assignmentRoulette',
|
||||
name: 'Rover Reassignment',
|
||||
goal: 300,
|
||||
async run(ctx) {
|
||||
const moved = ctx.rerollAssignments();
|
||||
ctx.sendAlert({
|
||||
color: '#8bc34a',
|
||||
title: 'Rover Reassignment',
|
||||
message: `Reassigned ${moved} user(s).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// Reward Definition: Camera Whiplash
|
||||
// Purpose: Defines the camera-whiplash deterrence reward and timing/strength settings. Scope: Supplies reusable reward metadata and execution parameters for chaos triggers.
|
||||
const STEP_MS = 220;
|
||||
const DURATION_MS = 30 * 1000;
|
||||
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
|
||||
|
||||
module.exports = {
|
||||
id: 'cameraWhiplash',
|
||||
name: 'Camera Wiggle',
|
||||
goal: 100,
|
||||
async run(ctx) {
|
||||
const rovers = ctx
|
||||
.listOnlineRovers()
|
||||
.filter((rover) => Boolean(rover?.cameraServo?.enabled));
|
||||
if (!rovers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
const timer = setInterval(() => {
|
||||
step += 1;
|
||||
rovers.forEach((rover) => {
|
||||
const nudge = (Math.random() * 24 - 12).toFixed(2);
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), {
|
||||
type: 'servo',
|
||||
servo: { nudge: Number(nudge) },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('cameraWhiplash servo failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
if (step >= STEPS) {
|
||||
clearInterval(timer);
|
||||
rovers.forEach((rover) => {
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), {
|
||||
type: 'servo',
|
||||
servo: { angle: 0 },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('cameraWhiplash servo reset failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
}, STEP_MS);
|
||||
|
||||
ctx.sendAlert({
|
||||
color: '#00bcd4',
|
||||
title: 'Camera Wiggle',
|
||||
message: `Wobble running for 30 seconds on ${rovers.length} rover(s).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
// Reward Definition: Chat Spam
|
||||
// Purpose: Defines the chat-spam reward for automated disruptive message bursts. Scope: Exposes metadata and effect settings consumed by reward execution.
|
||||
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const BURST_COUNT = 18;
|
||||
const MIN_BURST_SIZE = 2;
|
||||
const MAX_BURST_SIZE = 8;
|
||||
const MIN_BURST_DELAY_MS = 80;
|
||||
const MAX_BURST_DELAY_MS = 420;
|
||||
|
||||
function randLetter() {
|
||||
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||
}
|
||||
|
||||
function randInt(min, max) {
|
||||
return min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'chatSpam',
|
||||
name: 'Chat Spam',
|
||||
goal: 320,
|
||||
async run(ctx) {
|
||||
const nickname = randLetter();
|
||||
for (let burst = 0; burst < BURST_COUNT; burst += 1) {
|
||||
const burstSize = randInt(MIN_BURST_SIZE, MAX_BURST_SIZE);
|
||||
for (let i = 0; i < burstSize; i += 1) {
|
||||
const len = 3 + Math.floor(Math.random() * 14);
|
||||
let text = '';
|
||||
for (let j = 0; j < len; j += 1) {
|
||||
text += randLetter();
|
||||
}
|
||||
try {
|
||||
ctx.sendExternalMessage({
|
||||
nickname,
|
||||
role: 'spectator',
|
||||
roverId: null,
|
||||
text,
|
||||
});
|
||||
} catch {
|
||||
// ignore spam errors
|
||||
}
|
||||
}
|
||||
await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
// Reward Definition: Darkness
|
||||
// Purpose: Defines the darkness reward that alters visibility/lighting behavior. Scope: Encapsulates reward metadata and effect configuration for runtime execution.
|
||||
const DURATION_MS = 15 * 60 * 1000;
|
||||
const LIGHT_ENFORCE_TICK_MS = 3000;
|
||||
// Rover daemon semantics are inverted:
|
||||
// action "on" => IR LED on => nightVisionOn=false
|
||||
// action "off" => IR LED off => nightVisionOn=true
|
||||
function actionForNightVisionState(nightVisionOn) {
|
||||
return nightVisionOn ? 'off' : 'on';
|
||||
}
|
||||
|
||||
let activeTimer = null;
|
||||
let enforceLightsTimer = null;
|
||||
let nightVisionLockUntil = 0;
|
||||
|
||||
function isNightVisionBlocked() {
|
||||
return Date.now() < nightVisionLockUntil;
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
if (enforceLightsTimer) {
|
||||
clearInterval(enforceLightsTimer);
|
||||
enforceLightsTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function forceAllLightsOff(ctx) {
|
||||
const entities = ctx.getHomeAssistantEntities();
|
||||
await Promise.all(
|
||||
entities.map(async (entity) => {
|
||||
try {
|
||||
await ctx.setHomeAssistantEntityState(entity.id, 'off');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness force light off failed', { entityId: entity.id, error: err.message });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function stopDarkness(ctx, effect = {}) {
|
||||
clearTimers();
|
||||
nightVisionLockUntil = 0;
|
||||
|
||||
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
|
||||
await Promise.all(
|
||||
prevLights.map(async (entry) => {
|
||||
try {
|
||||
await ctx.setHomeAssistantEntityState(entry.id, entry.state === 'on' ? 'on' : 'off');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness restore light failed', { entityId: entry.id, error: err.message });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const prevLockState = effect?.prevLightLockState;
|
||||
if (prevLockState === 'on' || prevLockState === 'off') {
|
||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||
source: 'buttonbox:darknessRestore',
|
||||
forceApply: true,
|
||||
targetState: prevLockState,
|
||||
});
|
||||
} else {
|
||||
await ctx.setHomeAssistantLightsLockedOn(false, {
|
||||
source: 'buttonbox:darknessRestore',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness restore light lock failed', { error: err.message });
|
||||
}
|
||||
|
||||
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
|
||||
? effect.prevNightVision
|
||||
: {};
|
||||
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => {
|
||||
try {
|
||||
ctx.issueCommand(String(roverId), {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: actionForNightVisionState(Boolean(wasOn)) },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness restore nightVision failed', { roverId, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
ctx.clearEffect('darkness');
|
||||
}
|
||||
|
||||
async function startDarkness(ctx, effect) {
|
||||
clearTimers();
|
||||
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
|
||||
const remaining = Math.max(0, endsAt - Date.now());
|
||||
nightVisionLockUntil = endsAt;
|
||||
try {
|
||||
await ctx.setHomeAssistantLightsLockedOn(true, {
|
||||
source: 'buttonbox:darkness',
|
||||
forceApply: true,
|
||||
targetState: 'off',
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness set light lock failed', { error: err.message });
|
||||
}
|
||||
ctx.saveEffect('darkness', effect);
|
||||
|
||||
enforceLightsTimer = setInterval(() => {
|
||||
forceAllLightsOff(ctx).catch((err) => {
|
||||
ctx.logger.warn('darkness periodic light enforcement failed', { error: err.message });
|
||||
});
|
||||
}, LIGHT_ENFORCE_TICK_MS);
|
||||
|
||||
activeTimer = setTimeout(() => {
|
||||
stopDarkness(ctx, effect).catch((err) => {
|
||||
ctx.logger.warn('darkness stop failed', { error: err.message });
|
||||
});
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'darkness',
|
||||
name: 'Darkness',
|
||||
isNightVisionBlocked,
|
||||
goal: 400,
|
||||
async run(ctx) {
|
||||
const entities = ctx.getHomeAssistantEntities();
|
||||
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
|
||||
await forceAllLightsOff(ctx);
|
||||
|
||||
const prevNightVision = {};
|
||||
ctx.listOnlineRovers().forEach((rover) => {
|
||||
const state = rover?.nightVision?.state;
|
||||
const nightVisionOn = Boolean(state && state.nightVisionOn === true);
|
||||
prevNightVision[String(rover.id)] = nightVisionOn;
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: actionForNightVisionState(false) },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness nightVision off failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
const prevPolicy = ctx.getHomeAssistantLightPolicy?.() || null;
|
||||
const prevLightLockState = prevPolicy?.lockState || (prevPolicy?.lockedOn ? 'on' : null);
|
||||
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision, prevLightLockState };
|
||||
await startDarkness(ctx, effect);
|
||||
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 120 seconds.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
await stopDarkness(ctx, effect || {});
|
||||
return;
|
||||
}
|
||||
await startDarkness(ctx, effect);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Reward Definition: Discord Ping @everyone
|
||||
// Purpose: Defines the Discord reward that pings @everyone in general. Scope: Provides reward metadata and dispatch parameters for integration handlers.
|
||||
module.exports = {
|
||||
id: 'discordPingEveryone',
|
||||
name: 'PING @EVERYONE',
|
||||
goal: 7567,
|
||||
async run(ctx) {
|
||||
ctx.publishEvent({
|
||||
source: 'buttonBoxReward',
|
||||
type: 'buttonBox.discordPingEveryone',
|
||||
payload: {
|
||||
message: 'Pinged by button box masher!!',
|
||||
},
|
||||
});
|
||||
ctx.sendAlert({ color: '#ff00e1', title: 'PING @EVERYONE', message: 'Sent @everyone ping to Discord.' });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Reward Definition: Discord Stalker Ping
|
||||
// Purpose: Defines the Discord ping reward that notifies configured channels/users. Scope: Provides reward metadata and dispatch parameters for integration handlers.
|
||||
module.exports = {
|
||||
id: 'discordStalkerPing',
|
||||
name: 'Discord Ping',
|
||||
goal: 500,
|
||||
async run(ctx) {
|
||||
ctx.publishEvent({
|
||||
source: 'buttonBoxReward',
|
||||
type: 'buttonBox.discordStalkerPing',
|
||||
payload: {
|
||||
message: 'Pinged by button box masher!!',
|
||||
},
|
||||
});
|
||||
ctx.sendAlert({ color: '#5865f2', title: 'Stalker Ping', message: 'Sent stalker ping to Discord.' });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// Reward Definition: Dock Panic
|
||||
// Purpose: Defines the dock-panic deterrence reward behavior and metadata. Scope: Produces a deterministic action payload used by reward execution pipelines.
|
||||
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
||||
|
||||
module.exports = {
|
||||
id: 'dockPanic',
|
||||
name: 'All Dock',
|
||||
goal: 180,
|
||||
async run(ctx) {
|
||||
const rovers = ctx.listOnlineRovers();
|
||||
rovers.forEach((rover) => {
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), { type: 'raw', raw: DOCK_COMMAND_BASE64 });
|
||||
} catch (err) {
|
||||
ctx.logger.warn('dockPanic failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
ctx.sendAlert({
|
||||
color: '#ff9800',
|
||||
title: 'All Dock',
|
||||
message: `Issued seek-dock to ${rovers.length} rover(s).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
// Reward Definition: Ghost Typing Spam
|
||||
// Purpose: Defines the ghost-typing spam reward used for chat-based deterrence events. Scope: Exposes reward metadata and handler inputs for moderation/reward pipelines.
|
||||
const NAMES = ['ross', 'david', 'chirpet', 'caydu', 'meow', 'wawa'];
|
||||
const BURSTS = 120;
|
||||
const MIN_BURST_DELAY_MS = 70;
|
||||
const MAX_BURST_DELAY_MS = 350;
|
||||
|
||||
function randInt(min, max) {
|
||||
return min + Math.floor(Math.random() * (max - min + 1));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'ghostTypingSpam',
|
||||
name: 'Typing Spam',
|
||||
goal: 220,
|
||||
async run(ctx) {
|
||||
const active = new Set();
|
||||
for (let burst = 0; burst < BURSTS; burst += 1) {
|
||||
const name = NAMES[Math.floor(Math.random() * NAMES.length)] + String(Math.floor(Math.random() * 10));
|
||||
const on = Math.random() > 0.35;
|
||||
try {
|
||||
ctx.sendExternalTyping({
|
||||
nickname: name,
|
||||
role: 'spectator',
|
||||
roverId: null,
|
||||
isTyping: on,
|
||||
});
|
||||
if (on) active.add(name);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await sleep(randInt(MIN_BURST_DELAY_MS, MAX_BURST_DELAY_MS));
|
||||
}
|
||||
|
||||
active.forEach((ghost) => {
|
||||
try {
|
||||
ctx.sendExternalTyping({ nickname: ghost, role: 'spectator', roverId: null, isTyping: false });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ctx.sendAlert({ color: '#9c27b0', title: 'Typing Spam', message: 'Ghost typing burst started.' });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
// Reward Definition: Light Strobe
|
||||
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
||||
const STROBE_MS = 30 * 1000;
|
||||
const TICK_MS = 200;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
function applyAll(ctx, state) {
|
||||
const entities = ctx.getHomeAssistantEntities();
|
||||
entities.forEach((entity) => {
|
||||
ctx.setHomeAssistantEntityState(entity.id, state).catch((err) => {
|
||||
ctx.logger.warn('lightStrobe entity set failed', { entityId: entity.id, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startStrobe(ctx, effect = {}) {
|
||||
if (activeTimer) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
|
||||
const endsAt = Number(effect.endsAt || Date.now() + STROBE_MS);
|
||||
let on = Boolean(effect.on);
|
||||
ctx.saveEffect('lightStrobe', { endsAt, on });
|
||||
|
||||
activeTimer = setInterval(() => {
|
||||
if (Date.now() >= endsAt) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
ctx.clearEffect('lightStrobe');
|
||||
return;
|
||||
}
|
||||
on = !on;
|
||||
applyAll(ctx, on ? 'on' : 'off');
|
||||
ctx.saveEffect('lightStrobe', { endsAt, on });
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'lightStrobe',
|
||||
name: 'Light Strobe',
|
||||
goal: 300,
|
||||
async run(ctx) {
|
||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
ctx.clearEffect('lightStrobe');
|
||||
return;
|
||||
}
|
||||
startStrobe(ctx, effect);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// Reward Definition: Mode Jam
|
||||
// Purpose: Defines the mode-jam reward that interferes with mode/state transitions. Scope: Supplies effect metadata and execution inputs to reward orchestration code.
|
||||
const MIN_DURATION_MS = 5 * 60 * 1000;
|
||||
const MAX_DURATION_MS = 10 * 60 * 1000;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
function clearTimer() {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(ctx, effect = {}) {
|
||||
clearTimer();
|
||||
const prevMode = effect.prevMode;
|
||||
const prevReason = effect.prevReason;
|
||||
|
||||
if (prevMode) {
|
||||
try {
|
||||
ctx.setMode(prevMode, 'buttonbox:modeJamRestore');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam restore mode failed', { error: err.message });
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (prevReason == null || prevReason === '') {
|
||||
ctx.clearAdminReason('buttonbox:modeJamRestore');
|
||||
} else {
|
||||
ctx.setAdminReason(prevReason, 'buttonbox:modeJamRestore');
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam restore reason failed', { error: err.message });
|
||||
}
|
||||
|
||||
ctx.clearEffect('modeJam');
|
||||
}
|
||||
|
||||
function scheduleRestore(ctx, effect = {}) {
|
||||
clearTimer();
|
||||
const endsAt = Number(effect.endsAt || Date.now() + MIN_DURATION_MS);
|
||||
const remaining = Math.max(0, endsAt - Date.now());
|
||||
const next = { ...effect, endsAt };
|
||||
ctx.saveEffect('modeJam', next);
|
||||
activeTimer = setTimeout(() => {
|
||||
restore(ctx, next).catch((err) => {
|
||||
ctx.logger.warn('modeJam restore failed', { error: err.message });
|
||||
});
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'modeJam',
|
||||
name: 'Admin Lock',
|
||||
goal: 900,
|
||||
async run(ctx) {
|
||||
const durationMs =
|
||||
MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1));
|
||||
const endsAt = Date.now() + durationMs;
|
||||
const prevMode = ctx.getMode();
|
||||
const prevReason = ctx.getAdminReasonText();
|
||||
const jamReason = `Locked by button box until ${new Date(endsAt).toLocaleTimeString()}`;
|
||||
|
||||
try {
|
||||
ctx.setMode('admin', 'buttonbox:modeJam');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam set mode failed', { error: err.message });
|
||||
}
|
||||
try {
|
||||
ctx.setAdminReason(jamReason, 'buttonbox:modeJam');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam set reason failed', { error: err.message });
|
||||
}
|
||||
|
||||
scheduleRestore(ctx, {
|
||||
prevMode,
|
||||
prevReason,
|
||||
endsAt,
|
||||
});
|
||||
|
||||
ctx.sendAlert({ color: '#ff5722', title: 'Admin Mode', message: 'Server forced into admin mode temporarily.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
await restore(ctx, effect || {});
|
||||
return;
|
||||
}
|
||||
scheduleRestore(ctx, effect);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Reward Registry
|
||||
// Purpose: Registers and exports all deterrence/chaos reward definitions. Scope: Builds the canonical reward catalog consumed by button box and moderation flows.
|
||||
const dockPanic = require('./definitions/dockPanic');
|
||||
const cameraWhiplash = require('./definitions/cameraWhiplash');
|
||||
const lightStrobe = require('./definitions/lightStrobe');
|
||||
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
||||
const darkness = require('./definitions/darkness');
|
||||
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
||||
const discordPingEveryone = require('./definitions/discordPingEveryone');
|
||||
const modeJam = require('./definitions/modeJam');
|
||||
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
||||
const chatSpam = require('./definitions/chatSpam');
|
||||
|
||||
const orderedRewards = [
|
||||
dockPanic,
|
||||
cameraWhiplash,
|
||||
lightStrobe,
|
||||
ghostTypingSpam,
|
||||
darkness,
|
||||
discordStalkerPing,
|
||||
discordPingEveryone,
|
||||
modeJam,
|
||||
assignmentRoulette,
|
||||
chatSpam,
|
||||
];
|
||||
|
||||
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
|
||||
|
||||
function listRewards() {
|
||||
return orderedRewards.map((reward, idx) => ({ ...reward, number: idx + 1 }));
|
||||
}
|
||||
|
||||
function getRewardById(id) {
|
||||
return rewardById.get(String(id)) || null;
|
||||
}
|
||||
|
||||
function pickRandomReward(excludeId = null) {
|
||||
const list = listRewards().filter((reward) => !excludeId || reward.id !== excludeId);
|
||||
if (!list.length) return null;
|
||||
return list[Math.floor(Math.random() * list.length)] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRewards,
|
||||
getRewardById,
|
||||
pickRandomReward,
|
||||
};
|
||||
+6
-3
@@ -1,7 +1,10 @@
|
||||
// admin Log Service
|
||||
// Purpose: Defines the admin Log Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const { getRole, roleEvents } = require('./roleService');
|
||||
const { getSocketIp } = require('../helpers/ipResolver');
|
||||
const io = require('../../globals/io');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getSocketIp } = require('../../helpers/ipResolver');
|
||||
|
||||
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const MAX_HISTORY = 200;
|
||||
+10
-7
@@ -1,12 +1,15 @@
|
||||
// admin Reason Service
|
||||
// Purpose: Defines the admin Reason Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('adminReasonService');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('adminReasonService');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('admin-reason.json');
|
||||
const MAX_REASON_LENGTH = 240;
|
||||
|
||||
let cache = null;
|
||||
@@ -1,4 +1,7 @@
|
||||
const io = require('../globals/io');
|
||||
// alert Service
|
||||
// Purpose: Defines the alert Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const io = require('../../globals/io');
|
||||
|
||||
function sendAlert({ color, title, message, ts = Date.now() }) {
|
||||
const payload = {
|
||||
+52
-11
@@ -1,9 +1,12 @@
|
||||
// assignment Service
|
||||
// Purpose: Defines the assignment Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('assignment');
|
||||
const { MODES, getMode, modeEvents } = require('./modeManager');
|
||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('assignment');
|
||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
|
||||
const socketRefs = new Map(); // socketId -> socket
|
||||
const assignments = new Map(); // socketId -> roverId
|
||||
@@ -77,7 +80,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
||||
assignmentEvents.emit('update', socketId);
|
||||
});
|
||||
|
||||
function assignSocket(socket) {
|
||||
function assignSocket(socket, options = {}) {
|
||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||
return;
|
||||
}
|
||||
@@ -85,7 +88,9 @@ function assignSocket(socket) {
|
||||
if (assignments.has(socket.id)) {
|
||||
return;
|
||||
}
|
||||
const target = pickRover(socket);
|
||||
const target = pickRover(socket, {
|
||||
excludeRoverId: options.excludeRoverId || null,
|
||||
});
|
||||
if (!target) {
|
||||
waiting.add(socket.id);
|
||||
logger.info('No rover available, user waiting', socket.id);
|
||||
@@ -125,6 +130,10 @@ function reassignFromRover(roverId) {
|
||||
assignments.delete(socketId);
|
||||
continue;
|
||||
}
|
||||
const access = roverManager.canRequestControl(roverId, socket, { allowUser: true });
|
||||
if (access.ok) {
|
||||
continue;
|
||||
}
|
||||
roverManager.releaseControl(rid, socket);
|
||||
assignments.delete(socketId);
|
||||
assignSocket(socket);
|
||||
@@ -167,17 +176,25 @@ function forceRelease(roverId, socketId) {
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function pickRover(socket) {
|
||||
function pickRover(socket, options = {}) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
return null;
|
||||
}
|
||||
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
||||
if (!rover || rover.locked) return false;
|
||||
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
|
||||
if (!access.ok) return false;
|
||||
return true;
|
||||
});
|
||||
let candidates = allCandidates;
|
||||
const excludeRoverId = options?.excludeRoverId || null;
|
||||
if (excludeRoverId) {
|
||||
const withoutExcluded = allCandidates.filter((rover) => String(rover.id) !== String(excludeRoverId));
|
||||
if (withoutExcluded.length > 0) {
|
||||
candidates = withoutExcluded;
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -192,7 +209,7 @@ function pickRover(socket) {
|
||||
return 0;
|
||||
};
|
||||
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
|
||||
candidates.sort((a, b) => {
|
||||
const compare = (a, b) => {
|
||||
const aEmpty = idleRank(a);
|
||||
const bEmpty = idleRank(b);
|
||||
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
|
||||
@@ -205,8 +222,31 @@ function pickRover(socket) {
|
||||
return a.drivers.size - b.drivers.size;
|
||||
}
|
||||
return bDockRank - aDockRank;
|
||||
};
|
||||
candidates.sort(compare);
|
||||
const best = candidates[0];
|
||||
if (!best) return null;
|
||||
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
|
||||
if (!bestTier.length) return best;
|
||||
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
|
||||
}
|
||||
|
||||
function rerollAssignments() {
|
||||
const users = Array.from(socketRefs.values()).filter((socket) => socket && getRole(socket) === 'user');
|
||||
const previous = new Map(users.map((socket) => [socket.id, assignments.get(socket.id) || null]));
|
||||
users.forEach((socket) => {
|
||||
unassignSocket(socket);
|
||||
});
|
||||
return candidates[0];
|
||||
let moved = 0;
|
||||
users.forEach((socket) => {
|
||||
const prevRover = previous.get(socket.id) || null;
|
||||
assignSocket(socket, { excludeRoverId: prevRover });
|
||||
const nextRover = assignments.get(socket.id) || null;
|
||||
if (nextRover && prevRover && String(nextRover) !== String(prevRover)) {
|
||||
moved += 1;
|
||||
}
|
||||
});
|
||||
return moved;
|
||||
}
|
||||
|
||||
function describeAssignment(socketId) {
|
||||
@@ -226,6 +266,7 @@ module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
if (!socket || !roverId) return;
|
||||
@@ -1,657 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const roverManager = require('./roverManager');
|
||||
const turnService = require('./turnService');
|
||||
const { isVerified } = require('./verificationService');
|
||||
const videoSessions = require('./videoSessions');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
: '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
: 8 * 1024 * 1024;
|
||||
|
||||
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
||||
const workers = new Map(); // roverId -> worker
|
||||
const whipOwners = new Map(); // roverId -> socketId
|
||||
|
||||
function publishStateChange(roverId) {
|
||||
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
|
||||
}
|
||||
|
||||
function setState(roverId, next = {}) {
|
||||
const prev = states.get(roverId) || {};
|
||||
const merged = {
|
||||
state: next.state || prev.state || 'idle',
|
||||
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
|
||||
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
|
||||
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
states.set(roverId, merged);
|
||||
publishStateChange(roverId);
|
||||
}
|
||||
|
||||
function getAudioForwardState() {
|
||||
const payload = {};
|
||||
states.forEach((entry, roverId) => {
|
||||
payload[roverId] = { ...entry };
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeRoverId(roverId) {
|
||||
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||
}
|
||||
|
||||
function sanitizeFileStem(name) {
|
||||
return String(name || 'upload')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function extFromUpload(name, mime) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
const lowerMime = String(mime || '').toLowerCase();
|
||||
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
|
||||
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
|
||||
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
|
||||
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
|
||||
}
|
||||
|
||||
function ensureVipVerified(socket) {
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioForwardPermission(socket, roverId) {
|
||||
ensureVipVerified(socket);
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||
}
|
||||
if (!turnService.canDrive(roverId, socket)) {
|
||||
throw new Error('Only the current driver can play audio');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFifo(fifoPath) {
|
||||
try {
|
||||
const stat = fs.statSync(fifoPath);
|
||||
if (stat.isFIFO()) return;
|
||||
fs.unlinkSync(fifoPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function forcePublishStreamMode(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
||||
if (/,m=publish\b/.test(value)) return value;
|
||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
||||
}
|
||||
|
||||
function resolveForwardUrl(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const configured = record?.meta?.media?.audioForwardUrl;
|
||||
if (configured) return forcePublishStreamMode(configured);
|
||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
||||
roverId + streamSuffix,
|
||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||
}
|
||||
|
||||
function resolveForwardPathId(roverId) {
|
||||
return `${roverId}${streamSuffix}`;
|
||||
}
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return String(base).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhipUrl(pathId) {
|
||||
const prefix = getMediaPrefix();
|
||||
if (!prefix) {
|
||||
throw new Error('Server media base URL missing');
|
||||
}
|
||||
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
|
||||
}
|
||||
|
||||
function spawnFfmpeg(roverId, tag, args, options = {}) {
|
||||
const proc = spawn(ffmpegBin, args, {
|
||||
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn(`${tag} stderr`, { roverId, text });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function stopProc(proc, graceMs = 1200) {
|
||||
if (!proc || proc.killed) return;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, graceMs);
|
||||
}
|
||||
|
||||
function buildPublisherArgs(fifoPath, outputUrl) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-ac',
|
||||
'1',
|
||||
'-i',
|
||||
fifoPath,
|
||||
'-c:a',
|
||||
'libopus',
|
||||
'-b:a',
|
||||
'24000',
|
||||
'-ar:a',
|
||||
'16000',
|
||||
'-ac:a',
|
||||
'1',
|
||||
'-application',
|
||||
'lowdelay',
|
||||
'-frame_duration',
|
||||
'10',
|
||||
'-compression_level',
|
||||
'0',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-f',
|
||||
'mpegts',
|
||||
outputUrl,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSilenceWriterArgs() {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'anullsrc=channel_layout=mono:sample_rate=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-i',
|
||||
filePath,
|
||||
'-vn',
|
||||
'-af',
|
||||
'aresample=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function attachWriterPipe(worker, proc) {
|
||||
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
|
||||
writer.on('error', (err) => {
|
||||
const code = err?.code || 'unknown';
|
||||
if (code !== 'EPIPE') {
|
||||
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
proc.stdout.on('error', (err) => {
|
||||
logger.warn('writer stdout error', {
|
||||
roverId: worker?.roverId,
|
||||
code: err?.code || 'unknown',
|
||||
message: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
proc.stdout.pipe(writer);
|
||||
proc.on('exit', () => {
|
||||
writer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupUploadFile(worker) {
|
||||
if (!worker?.activeUploadPath) return;
|
||||
try {
|
||||
fs.unlinkSync(worker.activeUploadPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
function stopContentProc(worker) {
|
||||
if (!worker) return;
|
||||
if (worker.contentProc) {
|
||||
stopProc(worker.contentProc);
|
||||
}
|
||||
worker.contentProc = null;
|
||||
worker.contentKind = null;
|
||||
}
|
||||
|
||||
function startSilenceWriter(roverId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeOwnerSocketId = null;
|
||||
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'silence';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
if (code === 0 || signal === 'SIGTERM') return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'silence',
|
||||
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (workers.has(roverId)) startSilenceWriter(roverId);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath, ownerSocketId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = ownerSocketId || null;
|
||||
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'upload',
|
||||
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWorker(roverId) {
|
||||
if (!serviceEnabled) throw new Error('Audio forward disabled');
|
||||
if (!roverId) throw new Error('roverId required');
|
||||
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) throw new Error('Rover offline');
|
||||
|
||||
if (workers.has(roverId)) return workers.get(roverId);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
|
||||
ensureFifo(fifoPath);
|
||||
const outputUrl = resolveForwardUrl(roverId);
|
||||
|
||||
const keepaliveFd = fs.openSync(fifoPath, 'r+');
|
||||
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
|
||||
|
||||
const worker = {
|
||||
roverId,
|
||||
fifoPath,
|
||||
keepaliveFd,
|
||||
outputUrl,
|
||||
publisherProc: publisher,
|
||||
contentProc: null,
|
||||
contentKind: null,
|
||||
writerSeq: 0,
|
||||
activeOwnerSocketId: null,
|
||||
activeUploadPath: null,
|
||||
stopping: false,
|
||||
};
|
||||
workers.set(roverId, worker);
|
||||
|
||||
publisher.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.publisherProc !== publisher || current.stopping) return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: current.contentKind || 'publish',
|
||||
error: `publisher exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
startSilenceWriter(roverId);
|
||||
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
|
||||
return worker;
|
||||
}
|
||||
|
||||
function stopWorker(roverId) {
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, whipOwner);
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) return;
|
||||
|
||||
worker.stopping = true;
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
stopProc(worker.publisherProc);
|
||||
|
||||
try {
|
||||
fs.closeSync(worker.keepaliveFd);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(worker.fifoPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
workers.delete(roverId);
|
||||
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function writeUploadFile(roverId, payload = {}) {
|
||||
const { name, mime, dataBase64 } = payload || {};
|
||||
const ext = extFromUpload(name, mime);
|
||||
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
|
||||
if (!encoded) throw new Error('Upload payload missing');
|
||||
|
||||
const bytes = Buffer.from(encoded, 'base64');
|
||||
if (!bytes.length) throw new Error('Upload decode failed');
|
||||
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
|
||||
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
ensureWorker(roverId);
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
startUploadWriter(roverId, uploadPath, ownerSocketId);
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
stopWhipForRover(roverId, 'stop_playback');
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
function revokeWhipSessionForRover(roverId, ownerSocketId) {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
const pathId = resolveForwardPathId(roverId);
|
||||
videoSessions.revokeWhere(
|
||||
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
|
||||
);
|
||||
}
|
||||
|
||||
function stopWhipForRover(roverId, reason = 'unknown') {
|
||||
const ownerSocketId = whipOwners.get(roverId);
|
||||
if (!ownerSocketId) return;
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, ownerSocketId);
|
||||
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'mic-whip', error: err?.message || String(err), startedAt: null });
|
||||
}
|
||||
}
|
||||
|
||||
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
|
||||
if (whipOwners.get(roverId) === ownerSocketId) {
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (!ownerIsDriver || !ownerCanDrive) {
|
||||
stopWhipForRover(roverId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
|
||||
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (ownerIsDriver && ownerCanDrive) return;
|
||||
|
||||
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (!roverId) return;
|
||||
if (action === 'removed') {
|
||||
stopWorker(roverId);
|
||||
return;
|
||||
}
|
||||
if (action === 'upsert' && serviceEnabled) {
|
||||
if (whipOwners.has(roverId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
|
||||
if (!socketId || !roverId) return;
|
||||
if (action === 'remove' || action === 'add') {
|
||||
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
|
||||
}
|
||||
});
|
||||
|
||||
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
|
||||
if (!roverId) return;
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
|
||||
}
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload') return;
|
||||
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const roverId = String(payload?.roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, roverId);
|
||||
playUploadedAudio(roverId, payload, socket.id);
|
||||
cb({ success: true, roverId });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
const worker = workers.get(normalized);
|
||||
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
|
||||
throw new Error('Upload playback is owned by another session');
|
||||
}
|
||||
stopPlayback(normalized);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
stopWorker(normalized);
|
||||
whipOwners.set(normalized, socket.id);
|
||||
const pathId = resolveForwardPathId(normalized);
|
||||
revokeWhipSessionForRover(normalized, socket.id);
|
||||
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
|
||||
const whipUrl = buildWhipUrl(pathId);
|
||||
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('WHIP session not owned by this client');
|
||||
}
|
||||
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('Mic forwarding is owned by another session');
|
||||
}
|
||||
stopWhipForRover(normalized, 'client_stop');
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
workers.forEach((worker, roverId) => {
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
|
||||
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||
if (ownerSocketId !== socket.id) continue;
|
||||
stopWhipForRover(roverId, 'socket_disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
// audio Forward Service hooks
|
||||
// Purpose: Registers rover/turn lifecycle listeners and socket handlers for audio forwarding controls.
|
||||
// Scope: Keeps runtime behavior unchanged by delegating to injected core operations.
|
||||
function registerAudioForwardHooks(deps) {
|
||||
const {
|
||||
io,
|
||||
roverManager,
|
||||
turnService,
|
||||
logger,
|
||||
serviceEnabled,
|
||||
workers,
|
||||
whipOwners,
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
setState,
|
||||
stopOwnedAudioIfUnauthorized,
|
||||
stopWhipForRover,
|
||||
ensureAudioForwardPermission,
|
||||
playUploadedAudio,
|
||||
stopPlayback,
|
||||
resolveForwardPathId,
|
||||
revokeWhipSessionForRover,
|
||||
buildWhipUrl,
|
||||
videoSessions,
|
||||
startSilenceWriter,
|
||||
} = deps;
|
||||
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (!roverId) return;
|
||||
if (action === 'removed') {
|
||||
stopWorker(roverId);
|
||||
return;
|
||||
}
|
||||
if (action === 'upsert' && serviceEnabled) {
|
||||
if (whipOwners.has(roverId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, { state: 'error', source: 'init', error: err.message, startedAt: null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('driver', ({ socketId, roverId, action } = {}) => {
|
||||
if (!socketId || !roverId) return;
|
||||
if (action === 'remove' || action === 'add') {
|
||||
stopOwnedAudioIfUnauthorized(roverId, socketId, action);
|
||||
}
|
||||
});
|
||||
|
||||
turnService.turnEvents.on('activeDriver', ({ roverId } = {}) => {
|
||||
if (!roverId) return;
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
stopOwnedAudioIfUnauthorized(roverId, whipOwner, 'turn_change');
|
||||
}
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload') return;
|
||||
stopOwnedAudioIfUnauthorized(roverId, worker.activeOwnerSocketId, 'turn_change');
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audio:uploadPlay', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
const roverId = String(payload?.roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, roverId);
|
||||
playUploadedAudio(roverId, payload, socket.id);
|
||||
cb({ success: true, roverId });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:uploadStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
const worker = workers.get(normalized);
|
||||
if (worker && worker.contentKind === 'upload' && worker.activeOwnerSocketId !== socket.id) {
|
||||
throw new Error('Upload playback is owned by another session');
|
||||
}
|
||||
stopPlayback(normalized);
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStart', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
stopWorker(normalized);
|
||||
whipOwners.set(normalized, socket.id);
|
||||
const pathId = resolveForwardPathId(normalized);
|
||||
revokeWhipSessionForRover(normalized, socket.id);
|
||||
const token = videoSessions.createSession(socket, { type: 'roverMic', id: pathId });
|
||||
const whipUrl = buildWhipUrl(pathId);
|
||||
setState(normalized, { state: 'starting', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized, pathId, token, whipUrl });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipReady', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('WHIP session not owned by this client');
|
||||
}
|
||||
setState(normalized, { state: 'playing', source: 'mic-whip', error: null, startedAt: Date.now() });
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audio:micWhipStop', ({ roverId } = {}, cb = () => {}) => {
|
||||
try {
|
||||
const normalized = String(roverId || '').trim();
|
||||
ensureAudioForwardPermission(socket, normalized);
|
||||
if (whipOwners.get(normalized) && whipOwners.get(normalized) !== socket.id) {
|
||||
throw new Error('Mic forwarding is owned by another session');
|
||||
}
|
||||
stopWhipForRover(normalized, 'client_stop');
|
||||
cb({ success: true, roverId: normalized });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
workers.forEach((worker, roverId) => {
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== socket.id) return;
|
||||
logger.info('Stopping owned upload audio due to socket disconnect', { roverId, socketId: socket.id });
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
for (const [roverId, ownerSocketId] of whipOwners.entries()) {
|
||||
if (ownerSocketId !== socket.id) continue;
|
||||
stopWhipForRover(roverId, 'socket_disconnect');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerAudioForwardHooks,
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
// audio Forward Service
|
||||
// Purpose: Composes audio-forward policy, worker engine, and hook wiring into the public service API.
|
||||
// Scope: Keeps runtime behavior unchanged while making this entrypoint a thin orchestration layer.
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioForwardService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const roverManager = require('../roverManager');
|
||||
const turnService = require('../turnService');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const { createAudioForwardPolicy } = require('./policy');
|
||||
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||
const { registerAudioForwardHooks } = require('./hooks');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
const audioForwardConfig = config.audioForward || {};
|
||||
const mediaConfig = config.media || {};
|
||||
const serviceEnabled = audioForwardConfig.enabled !== false;
|
||||
const ffmpegBin = audioForwardConfig.ffmpegBin || 'ffmpeg';
|
||||
const streamSuffix =
|
||||
typeof audioForwardConfig.streamSuffix === 'string' && audioForwardConfig.streamSuffix.trim()
|
||||
? audioForwardConfig.streamSuffix.trim()
|
||||
: '-fwd';
|
||||
const runtimeDir = path.resolve(audioForwardConfig.runtimeDir || '/tmp/mrr-audio-forward');
|
||||
const uploadsDir = path.join(runtimeDir, 'uploads');
|
||||
const maxUploadBytes = Number.isFinite(audioForwardConfig.maxUploadBytes)
|
||||
? Math.max(256 * 1024, Math.floor(audioForwardConfig.maxUploadBytes))
|
||||
: 8 * 1024 * 1024;
|
||||
|
||||
const states = new Map(); // roverId -> { state, source, error, startedAt, updatedAt }
|
||||
const workers = new Map(); // roverId -> worker
|
||||
const whipOwners = new Map(); // roverId -> socketId
|
||||
|
||||
function publishStateChange(roverId) {
|
||||
audioForwardEvents.emit('change', { roverId, state: states.get(roverId) || null });
|
||||
}
|
||||
|
||||
function setState(roverId, next = {}) {
|
||||
const prev = states.get(roverId) || {};
|
||||
const merged = {
|
||||
state: next.state || prev.state || 'idle',
|
||||
source: Object.prototype.hasOwnProperty.call(next, 'source') ? next.source : prev.source || 'silence',
|
||||
error: Object.prototype.hasOwnProperty.call(next, 'error') ? next.error : prev.error || null,
|
||||
startedAt: Object.prototype.hasOwnProperty.call(next, 'startedAt') ? next.startedAt : prev.startedAt || null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
states.set(roverId, merged);
|
||||
publishStateChange(roverId);
|
||||
}
|
||||
|
||||
function getAudioForwardState() {
|
||||
const payload = {};
|
||||
states.forEach((entry, roverId) => {
|
||||
payload[roverId] = { ...entry };
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
const audioForwardPolicy = createAudioForwardPolicy({
|
||||
isVerified,
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
mediaConfig,
|
||||
});
|
||||
const {
|
||||
ensureAudioForwardPermission,
|
||||
resolveForwardUrl,
|
||||
resolveForwardPathId,
|
||||
buildWhipUrl,
|
||||
} = audioForwardPolicy;
|
||||
|
||||
const workerEngine = createAudioForwardWorkerEngine({
|
||||
logger,
|
||||
io,
|
||||
roverManager,
|
||||
turnService,
|
||||
videoSessions,
|
||||
serviceEnabled,
|
||||
ffmpegBin,
|
||||
runtimeDir,
|
||||
uploadsDir,
|
||||
maxUploadBytes,
|
||||
workers,
|
||||
whipOwners,
|
||||
setState,
|
||||
resolveForwardUrl,
|
||||
resolveForwardPathId,
|
||||
});
|
||||
|
||||
const {
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
playUploadedAudio,
|
||||
stopPlayback,
|
||||
revokeWhipSessionForRover,
|
||||
stopWhipForRover,
|
||||
stopOwnedAudioIfUnauthorized,
|
||||
startSilenceWriter,
|
||||
} = workerEngine;
|
||||
|
||||
registerAudioForwardHooks({
|
||||
io,
|
||||
roverManager,
|
||||
turnService,
|
||||
logger,
|
||||
serviceEnabled,
|
||||
workers,
|
||||
whipOwners,
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
setState,
|
||||
stopOwnedAudioIfUnauthorized,
|
||||
stopWhipForRover,
|
||||
ensureAudioForwardPermission,
|
||||
playUploadedAudio,
|
||||
stopPlayback,
|
||||
resolveForwardPathId,
|
||||
revokeWhipSessionForRover,
|
||||
buildWhipUrl,
|
||||
videoSessions,
|
||||
startSilenceWriter,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// audio Forward Service policy
|
||||
// Purpose: Encapsulates permission checks and media path/url derivation helpers.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating validation and path-construction logic.
|
||||
function createAudioForwardPolicy(deps) {
|
||||
const {
|
||||
isVerified,
|
||||
roverManager,
|
||||
turnService,
|
||||
streamSuffix,
|
||||
mediaConfig,
|
||||
} = deps;
|
||||
|
||||
function ensureVipVerified(socket) {
|
||||
if (!isVerified(socket)) {
|
||||
throw new Error('VIP verification required');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioForwardPermission(socket, roverId) {
|
||||
ensureVipVerified(socket);
|
||||
if (!roverManager.isDriver(roverId, socket)) {
|
||||
throw new Error('Audio forwarding is only allowed on your own rover');
|
||||
}
|
||||
if (!turnService.canDrive(roverId, socket)) {
|
||||
throw new Error('Only the current driver can play audio');
|
||||
}
|
||||
}
|
||||
|
||||
function forcePublishStreamMode(rawUrl) {
|
||||
const value = String(rawUrl || '').trim();
|
||||
if (!value) return '';
|
||||
if (!/[?&]streamid=#!::/.test(value)) return value;
|
||||
if (/,m=publish\b/.test(value)) return value;
|
||||
if (/,m=[a-zA-Z]+\b/.test(value)) return value.replace(/,m=[a-zA-Z]+\b/, ',m=publish');
|
||||
return value.replace(/([?&]streamid=#!::[^&]*)/, '$1,m=publish');
|
||||
}
|
||||
|
||||
function resolveForwardUrl(roverId) {
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const configured = record?.meta?.media?.audioForwardUrl;
|
||||
if (configured) return forcePublishStreamMode(configured);
|
||||
return `srt://127.0.0.1:9000?streamid=#!::r=${encodeURIComponent(
|
||||
roverId + streamSuffix,
|
||||
)},m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316`;
|
||||
}
|
||||
|
||||
function resolveForwardPathId(roverId) {
|
||||
return `${roverId}${streamSuffix}`;
|
||||
}
|
||||
|
||||
function getMediaPrefix() {
|
||||
const base = mediaConfig.whepBaseUrl;
|
||||
if (!base) return '';
|
||||
try {
|
||||
const parsed = new URL(base);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return String(base).replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhipUrl(pathId) {
|
||||
const prefix = getMediaPrefix();
|
||||
if (!prefix) {
|
||||
throw new Error('Server media base URL missing');
|
||||
}
|
||||
return `${prefix}/${encodeURIComponent(pathId)}/whip`;
|
||||
}
|
||||
|
||||
return {
|
||||
ensureVipVerified,
|
||||
ensureAudioForwardPermission,
|
||||
resolveForwardUrl,
|
||||
resolveForwardPathId,
|
||||
buildWhipUrl,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAudioForwardPolicy,
|
||||
};
|
||||
@@ -0,0 +1,464 @@
|
||||
// audio Forward Service worker engine
|
||||
// Purpose: Owns ffmpeg worker lifecycle, upload playback, and WHIP/session ownership transitions.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating process/file-pipeline logic from service wiring.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
|
||||
function createAudioForwardWorkerEngine(deps) {
|
||||
const {
|
||||
logger,
|
||||
io,
|
||||
roverManager,
|
||||
turnService,
|
||||
videoSessions,
|
||||
serviceEnabled,
|
||||
ffmpegBin,
|
||||
runtimeDir,
|
||||
uploadsDir,
|
||||
maxUploadBytes,
|
||||
workers,
|
||||
whipOwners,
|
||||
setState,
|
||||
resolveForwardUrl,
|
||||
resolveForwardPathId,
|
||||
} = deps;
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
fs.mkdirSync(runtimeDir, { recursive: true });
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeRoverId(roverId) {
|
||||
return String(roverId || '').replace(/[^a-zA-Z0-9_-]+/g, '_');
|
||||
}
|
||||
|
||||
function sanitizeFileStem(name) {
|
||||
return String(name || 'upload')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function extFromUpload(name, mime) {
|
||||
const lowerName = String(name || '').toLowerCase();
|
||||
const lowerMime = String(mime || '').toLowerCase();
|
||||
if (lowerName.endsWith('.mp3') || lowerMime === 'audio/mpeg' || lowerMime === 'audio/mp3') return '.mp3';
|
||||
if (lowerName.endsWith('.wav') || lowerMime === 'audio/wav' || lowerMime === 'audio/x-wav') return '.wav';
|
||||
if (lowerName.endsWith('.ogg') || lowerMime === 'audio/ogg') return '.ogg';
|
||||
throw new Error('Unsupported upload format (allowed: mp3, wav, ogg)');
|
||||
}
|
||||
|
||||
function ensureFifo(fifoPath) {
|
||||
try {
|
||||
const stat = fs.statSync(fifoPath);
|
||||
if (stat.isFIFO()) return;
|
||||
fs.unlinkSync(fifoPath);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
const result = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`mkfifo failed: ${result.stderr || result.stdout || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnFfmpeg(roverId, tag, args, options = {}) {
|
||||
const proc = spawn(ffmpegBin, args, {
|
||||
stdio: [options.captureStdin ? 'pipe' : 'ignore', options.captureStdout ? 'pipe' : 'ignore', 'pipe'],
|
||||
});
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn(`${tag} stderr`, { roverId, text });
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn(`${tag} spawn error`, { roverId, message: err?.message || String(err) });
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
function stopProc(proc, graceMs = 1200) {
|
||||
if (!proc || proc.killed) return;
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, graceMs);
|
||||
}
|
||||
|
||||
function buildPublisherArgs(fifoPath, outputUrl) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ar',
|
||||
'16000',
|
||||
'-ac',
|
||||
'1',
|
||||
'-i',
|
||||
fifoPath,
|
||||
'-c:a',
|
||||
'libopus',
|
||||
'-b:a',
|
||||
'24000',
|
||||
'-ar:a',
|
||||
'16000',
|
||||
'-ac:a',
|
||||
'1',
|
||||
'-application',
|
||||
'lowdelay',
|
||||
'-frame_duration',
|
||||
'10',
|
||||
'-compression_level',
|
||||
'0',
|
||||
'-fflags',
|
||||
'nobuffer',
|
||||
'-flush_packets',
|
||||
'1',
|
||||
'-muxdelay',
|
||||
'0',
|
||||
'-muxpreload',
|
||||
'0',
|
||||
'-f',
|
||||
'mpegts',
|
||||
outputUrl,
|
||||
];
|
||||
}
|
||||
|
||||
function buildSilenceWriterArgs() {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'anullsrc=channel_layout=mono:sample_rate=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'warning',
|
||||
'-re',
|
||||
'-i',
|
||||
filePath,
|
||||
'-vn',
|
||||
'-af',
|
||||
'aresample=16000',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ac',
|
||||
'1',
|
||||
'-ar',
|
||||
'16000',
|
||||
'pipe:1',
|
||||
];
|
||||
}
|
||||
|
||||
function attachWriterPipe(worker, proc) {
|
||||
const writer = fs.createWriteStream(worker.fifoPath, { flags: 'w' });
|
||||
writer.on('error', (err) => {
|
||||
const code = err?.code || 'unknown';
|
||||
if (code !== 'EPIPE') {
|
||||
logger.warn('writer pipe error', { roverId: worker?.roverId, code, message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
proc.stdout.on('error', (err) => {
|
||||
logger.warn('writer stdout error', {
|
||||
roverId: worker?.roverId,
|
||||
code: err?.code || 'unknown',
|
||||
message: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
proc.stdout.pipe(writer);
|
||||
proc.on('exit', () => {
|
||||
writer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupUploadFile(worker) {
|
||||
if (!worker?.activeUploadPath) return;
|
||||
try {
|
||||
fs.unlinkSync(worker.activeUploadPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
function stopContentProc(worker) {
|
||||
if (!worker) return;
|
||||
if (worker.contentProc) {
|
||||
stopProc(worker.contentProc);
|
||||
}
|
||||
worker.contentProc = null;
|
||||
worker.contentKind = null;
|
||||
}
|
||||
|
||||
function startSilenceWriter(roverId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeOwnerSocketId = null;
|
||||
const proc = spawnFfmpeg(roverId, 'silence-writer', buildSilenceWriterArgs(), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'silence';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
if (code === 0 || signal === 'SIGTERM') return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'silence',
|
||||
error: `silence writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (workers.has(roverId)) startSilenceWriter(roverId);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath, ownerSocketId) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = ownerSocketId || null;
|
||||
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.stopping) return;
|
||||
if (current.writerSeq !== seq || current.contentProc !== proc) return;
|
||||
current.contentProc = null;
|
||||
current.contentKind = null;
|
||||
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'upload',
|
||||
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
startSilenceWriter(roverId);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWorker(roverId) {
|
||||
if (!serviceEnabled) throw new Error('Audio forward disabled');
|
||||
if (!roverId) throw new Error('roverId required');
|
||||
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record || !record.ws) throw new Error('Rover offline');
|
||||
|
||||
if (workers.has(roverId)) return workers.get(roverId);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const fifoPath = path.join(runtimeDir, `${sanitizeRoverId(roverId)}.pcm`);
|
||||
ensureFifo(fifoPath);
|
||||
const outputUrl = resolveForwardUrl(roverId);
|
||||
|
||||
const keepaliveFd = fs.openSync(fifoPath, 'r+');
|
||||
const publisher = spawnFfmpeg(roverId, 'publisher', buildPublisherArgs(fifoPath, outputUrl));
|
||||
|
||||
const worker = {
|
||||
roverId,
|
||||
fifoPath,
|
||||
keepaliveFd,
|
||||
outputUrl,
|
||||
publisherProc: publisher,
|
||||
contentProc: null,
|
||||
contentKind: null,
|
||||
writerSeq: 0,
|
||||
activeOwnerSocketId: null,
|
||||
activeUploadPath: null,
|
||||
stopping: false,
|
||||
};
|
||||
workers.set(roverId, worker);
|
||||
|
||||
publisher.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
if (!current || current.publisherProc !== publisher || current.stopping) return;
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: current.contentKind || 'publish',
|
||||
error: `publisher exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
startSilenceWriter(roverId);
|
||||
logger.info('Audio forward worker ready', { roverId, outputUrl, fifoPath });
|
||||
return worker;
|
||||
}
|
||||
|
||||
function revokeWhipSessionForRover(roverId, ownerSocketId) {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
const pathId = resolveForwardPathId(roverId);
|
||||
videoSessions.revokeWhere(
|
||||
(info) => info?.socketId === ownerSocketId && info?.sourceType === 'roverMic' && info?.sourceId === pathId,
|
||||
);
|
||||
}
|
||||
|
||||
function stopWorker(roverId) {
|
||||
const whipOwner = whipOwners.get(roverId);
|
||||
if (whipOwner) {
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, whipOwner);
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker) return;
|
||||
|
||||
worker.stopping = true;
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
stopProc(worker.publisherProc);
|
||||
|
||||
try {
|
||||
fs.closeSync(worker.keepaliveFd);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(worker.fifoPath);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
workers.delete(roverId);
|
||||
setState(roverId, { state: 'offline', source: 'none', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function writeUploadFile(roverId, payload = {}) {
|
||||
const { name, mime, dataBase64 } = payload || {};
|
||||
const ext = extFromUpload(name, mime);
|
||||
const encoded = typeof dataBase64 === 'string' ? dataBase64.trim() : '';
|
||||
if (!encoded) throw new Error('Upload payload missing');
|
||||
|
||||
const bytes = Buffer.from(encoded, 'base64');
|
||||
if (!bytes.length) throw new Error('Upload decode failed');
|
||||
if (bytes.length > maxUploadBytes) throw new Error(`Upload too large (max ${maxUploadBytes} bytes)`);
|
||||
|
||||
ensureRuntimeDir();
|
||||
const stem = sanitizeFileStem(name || `upload-${Date.now()}`);
|
||||
const filePath = path.join(uploadsDir, `${sanitizeRoverId(roverId)}-${Date.now()}-${stem}${ext}`);
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function stopWhipForRover(roverId, reason = 'unknown') {
|
||||
const ownerSocketId = whipOwners.get(roverId);
|
||||
if (!ownerSocketId) return;
|
||||
whipOwners.delete(roverId);
|
||||
revokeWhipSessionForRover(roverId, ownerSocketId);
|
||||
logger.info('Stopping WHIP mic session', { roverId, ownerSocketId, reason });
|
||||
try {
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
} catch (err) {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'mic-whip',
|
||||
error: err?.message || String(err),
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function playUploadedAudio(roverId, payload = {}, ownerSocketId = null) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
ensureWorker(roverId);
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
startUploadWriter(roverId, uploadPath, ownerSocketId);
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
stopWhipForRover(roverId, 'stop_playback');
|
||||
ensureWorker(roverId);
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
function stopOwnedAudioIfUnauthorized(roverId, ownerSocketId, reason = 'driver_change') {
|
||||
if (!roverId || !ownerSocketId) return;
|
||||
|
||||
if (whipOwners.get(roverId) === ownerSocketId) {
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (!ownerIsDriver || !ownerCanDrive) {
|
||||
stopWhipForRover(roverId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.contentKind !== 'upload' || worker.activeOwnerSocketId !== ownerSocketId) return;
|
||||
|
||||
const ownerSocket = io.sockets.sockets.get(ownerSocketId);
|
||||
const ownerIsDriver = ownerSocket ? roverManager.isDriver(roverId, ownerSocket) : false;
|
||||
const ownerCanDrive = ownerSocket ? turnService.canDrive(roverId, ownerSocket) : false;
|
||||
if (ownerIsDriver && ownerCanDrive) return;
|
||||
|
||||
logger.info('Stopping upload audio due to ownership/driver change', { roverId, ownerSocketId, reason });
|
||||
startSilenceWriter(roverId);
|
||||
}
|
||||
|
||||
return {
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
playUploadedAudio,
|
||||
stopPlayback,
|
||||
revokeWhipSessionForRover,
|
||||
stopWhipForRover,
|
||||
stopOwnedAudioIfUnauthorized,
|
||||
startSilenceWriter,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAudioForwardWorkerEngine,
|
||||
};
|
||||
+12
-9
@@ -1,16 +1,19 @@
|
||||
// audio Levels Service
|
||||
// Purpose: Defines the audio Levels Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'audio-levels.json');
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||
const config = loadConfig();
|
||||
const configuredDefaults = config.audioLevels || {};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// auth Service
|
||||
// Purpose: Defines the auth Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const bcrypt = require('bcrypt');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('authService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { clearLockdownTimer } = require('./lockdownGuard');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { setRole } = require('./roleService');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('authService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { setRole } = require('../roleService');
|
||||
|
||||
const config = loadConfig();
|
||||
const admins = config.admins || [];
|
||||
@@ -1,6 +1,9 @@
|
||||
const logger = require('../globals/logger').child('batteryManager');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { managerEvents, lockRover, rovers } = require('./roverManager');
|
||||
// battery Manager
|
||||
// Purpose: Defines the battery Manager module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const logger = require('../../globals/logger').child('batteryManager');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { managerEvents, lockRover, rovers } = require('../roverManager');
|
||||
|
||||
const STATES = {
|
||||
NORMAL: 'normal',
|
||||
@@ -0,0 +1,201 @@
|
||||
// button Box Service core
|
||||
// Purpose: Handles reward execution, effect persistence/recovery, and press application workflows.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating domain logic from HTTP transport wiring.
|
||||
function createButtonBoxCore(deps) {
|
||||
const {
|
||||
io,
|
||||
logger,
|
||||
getRewardById,
|
||||
roverManager,
|
||||
issueCommand,
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
sendExternalTyping,
|
||||
sendExternalMessage,
|
||||
sendSystemMessage,
|
||||
getMode,
|
||||
setMode,
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
assignmentService,
|
||||
getHomeAssistantState,
|
||||
setHomeAssistantEntityState,
|
||||
setHomeAssistantLightsLockedOn,
|
||||
store,
|
||||
} = deps;
|
||||
|
||||
function publishUpdated() {
|
||||
publishEvent({
|
||||
source: 'buttonBox',
|
||||
type: 'buttonBox.updated',
|
||||
payload: { updatedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
|
||||
function saveEffect(effectId, payload = {}, options = {}) {
|
||||
const state = store.getState();
|
||||
state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {};
|
||||
state.effects[effectId] = payload;
|
||||
store.writeState();
|
||||
if (options.broadcast) {
|
||||
publishUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
function clearEffect(effectId, options = {}) {
|
||||
const state = store.getState();
|
||||
if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) {
|
||||
delete state.effects[effectId];
|
||||
store.writeState();
|
||||
if (options.broadcast) {
|
||||
publishUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRewardContext() {
|
||||
return {
|
||||
logger,
|
||||
issueCommand,
|
||||
listOnlineRovers: () => roverManager.getRoster(),
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
sendExternalTyping,
|
||||
sendExternalMessage,
|
||||
sendSystemMessage,
|
||||
getMode,
|
||||
setMode: (mode, source = 'buttonbox') =>
|
||||
setMode(
|
||||
mode,
|
||||
{ data: { role: 'admin', user: { username: source } } },
|
||||
{ force: true },
|
||||
),
|
||||
getAdminReasonText: () => getAdminReason()?.text || null,
|
||||
setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }),
|
||||
clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }),
|
||||
rerollAssignments: () => assignmentService.rerollAssignments(),
|
||||
getHomeAssistantEntities: () => {
|
||||
const entities = getHomeAssistantState()?.entities;
|
||||
return Array.isArray(entities) ? entities : [];
|
||||
},
|
||||
getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null,
|
||||
setHomeAssistantEntityState: (entityId, state) =>
|
||||
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
|
||||
setHomeAssistantLightsLockedOn: (next, options = {}) =>
|
||||
setHomeAssistantLightsLockedOn(next, options),
|
||||
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
|
||||
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
|
||||
};
|
||||
}
|
||||
|
||||
async function runRewardForButton(button) {
|
||||
const reward = getRewardById(button.rewardId);
|
||||
if (!reward) {
|
||||
store.assignNewReward(button);
|
||||
return;
|
||||
}
|
||||
const completedCount = Number.isFinite(button.count) ? button.count : 0;
|
||||
const completedGoal = Number.isFinite(button.goal) ? button.goal : 0;
|
||||
const ctx = buildRewardContext();
|
||||
try {
|
||||
await reward.run(ctx);
|
||||
} catch (err) {
|
||||
logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message });
|
||||
}
|
||||
io.emit('buttonBox:rewardRun', {
|
||||
buttonId: button.id,
|
||||
rewardId: reward.id,
|
||||
rewardName: reward.name || reward.id,
|
||||
count: completedCount,
|
||||
goal: completedGoal,
|
||||
ts: Date.now(),
|
||||
});
|
||||
button.lastRewardAt = Date.now();
|
||||
button.count = 0;
|
||||
store.assignNewReward(button, { excludeRewardId: reward.id });
|
||||
}
|
||||
|
||||
async function applyPress(buttonId) {
|
||||
const state = store.getState();
|
||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
||||
if (!button) {
|
||||
throw new Error('Unknown button');
|
||||
}
|
||||
|
||||
button.count += 1;
|
||||
button.lastIncrementAt = Date.now();
|
||||
store.writeState();
|
||||
|
||||
io.emit('buttonBox:increment', {
|
||||
buttonId,
|
||||
count: button.count,
|
||||
ts: button.lastIncrementAt,
|
||||
});
|
||||
|
||||
if (button.count >= button.goal) {
|
||||
await runRewardForButton(button);
|
||||
store.writeState();
|
||||
}
|
||||
|
||||
publishUpdated();
|
||||
return store.clone(button);
|
||||
}
|
||||
|
||||
async function addCount(buttonId, amount = 1) {
|
||||
const state = store.getState();
|
||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
||||
if (!button) {
|
||||
throw new Error('Unknown button');
|
||||
}
|
||||
const inc = Math.max(1, Math.floor(Number(amount) || 0));
|
||||
button.count += inc;
|
||||
button.lastIncrementAt = Date.now();
|
||||
store.writeState();
|
||||
|
||||
io.emit('buttonBox:increment', {
|
||||
buttonId,
|
||||
count: button.count,
|
||||
ts: button.lastIncrementAt,
|
||||
});
|
||||
|
||||
while (button.count >= button.goal) {
|
||||
await runRewardForButton(button);
|
||||
store.writeState();
|
||||
}
|
||||
|
||||
publishUpdated();
|
||||
return store.clone(button);
|
||||
}
|
||||
|
||||
async function recoverEffects() {
|
||||
const state = store.getState();
|
||||
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
||||
const ctx = buildRewardContext();
|
||||
|
||||
for (const [effectId, payload] of Object.entries(effects)) {
|
||||
const reward = getRewardById(effectId);
|
||||
if (!reward || typeof reward.recover !== 'function') {
|
||||
clearEffect(effectId, { broadcast: false });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await reward.recover(ctx, payload);
|
||||
} catch (err) {
|
||||
logger.warn('Effect recovery failed', { effectId, error: err.message });
|
||||
clearEffect(effectId, { broadcast: false });
|
||||
}
|
||||
}
|
||||
publishUpdated();
|
||||
}
|
||||
|
||||
return {
|
||||
applyPress,
|
||||
addCount,
|
||||
recoverEffects,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createButtonBoxCore,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// button Box Service HTTP route
|
||||
// Purpose: Registers button-box press endpoint and request validation against local-network policy.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating transport/validation from domain logic.
|
||||
const express = require('express');
|
||||
|
||||
function registerButtonBoxRoute(deps) {
|
||||
const {
|
||||
app,
|
||||
logger,
|
||||
buttonCount,
|
||||
normalizeIp,
|
||||
isLocalNetwork,
|
||||
applyPress,
|
||||
} = deps;
|
||||
|
||||
function parseButtonId(body) {
|
||||
if (typeof body !== 'string') return null;
|
||||
const value = Number.parseInt(body.trim(), 10);
|
||||
if (Number.isFinite(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSocketIp(req) {
|
||||
return (
|
||||
req?.socket?.remoteAddress ||
|
||||
req?.connection?.remoteAddress ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function denyIfNotLocal(req, res) {
|
||||
const ip = normalizeIp(getSocketIp(req));
|
||||
if (isLocalNetwork(ip)) {
|
||||
return false;
|
||||
}
|
||||
logger.warn('Rejected non-local button press request', { ip: ip || null });
|
||||
res.status(403).json({ error: 'Button presses must originate from local network' });
|
||||
return true;
|
||||
}
|
||||
|
||||
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
|
||||
if (denyIfNotLocal(req, res)) return;
|
||||
const buttonId = parseButtonId(req.body);
|
||||
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) {
|
||||
res.status(400).json({ error: 'button must be 1-4' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const button = await applyPress(buttonId);
|
||||
res.json({ success: true, button });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message || 'Button processing failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerButtonBoxRoute,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
// button Box Service
|
||||
// Purpose: Composes button-box storage, core reward workflows, and HTTP route wiring.
|
||||
// Scope: Keeps runtime behavior unchanged while making this entrypoint a thin composition layer.
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('buttonBoxService');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { getRewardById, listRewards } = require('../../rewards');
|
||||
const roverManager = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { sendExternalTyping, sendExternalMessage, sendSystemMessage } = require('../chatService');
|
||||
const { setMode, getMode } = require('../modeManager');
|
||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const {
|
||||
getState: getHomeAssistantState,
|
||||
setEntityState: setHomeAssistantEntityState,
|
||||
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
||||
} = require('../homeAssistantService');
|
||||
const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
||||
const { createButtonBoxStore } = require('./store');
|
||||
const { createButtonBoxCore } = require('./core');
|
||||
const { registerButtonBoxRoute } = require('./httpRoute');
|
||||
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('buttonbox-state.json');
|
||||
const BUTTON_COUNT = 4;
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
const store = createButtonBoxStore({
|
||||
logger,
|
||||
getRewardById,
|
||||
listRewards,
|
||||
dataDir: DATA_DIR,
|
||||
storePath: STORE_PATH,
|
||||
buttonCount: BUTTON_COUNT,
|
||||
storeVersion: STORE_VERSION,
|
||||
});
|
||||
|
||||
const core = createButtonBoxCore({
|
||||
io,
|
||||
logger,
|
||||
getRewardById,
|
||||
roverManager,
|
||||
issueCommand,
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
sendExternalTyping,
|
||||
sendExternalMessage,
|
||||
sendSystemMessage,
|
||||
getMode,
|
||||
setMode,
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
assignmentService,
|
||||
getHomeAssistantState,
|
||||
setHomeAssistantEntityState,
|
||||
setHomeAssistantLightsLockedOn,
|
||||
store,
|
||||
});
|
||||
|
||||
registerButtonBoxRoute({
|
||||
app,
|
||||
logger,
|
||||
buttonCount: BUTTON_COUNT,
|
||||
normalizeIp,
|
||||
isLocalNetwork,
|
||||
applyPress: core.applyPress,
|
||||
});
|
||||
|
||||
store.loadState();
|
||||
core.recoverEffects().catch((err) => {
|
||||
logger.warn('Button box effect recovery failed', err.message);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getButtonBoxState: store.getStateClone,
|
||||
addButtonBoxCount: core.addCount,
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
// button Box Service store
|
||||
// Purpose: Owns persisted button-box state shape, normalization, and atomic file read/write operations.
|
||||
// Scope: Keeps runtime behavior unchanged by isolating state persistence mechanics from reward/HTTP orchestration.
|
||||
const fs = require('fs');
|
||||
|
||||
function createButtonBoxStore(deps) {
|
||||
const {
|
||||
logger,
|
||||
getRewardById,
|
||||
listRewards,
|
||||
dataDir,
|
||||
storePath,
|
||||
buttonCount,
|
||||
storeVersion,
|
||||
} = deps;
|
||||
|
||||
let state = null;
|
||||
|
||||
function createDefaultButton(id) {
|
||||
return {
|
||||
id,
|
||||
count: 0,
|
||||
rewardId: null,
|
||||
rewardName: null,
|
||||
rewardNumber: null,
|
||||
goal: null,
|
||||
lastIncrementAt: null,
|
||||
lastRewardAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultState() {
|
||||
return {
|
||||
version: storeVersion,
|
||||
updatedAt: Date.now(),
|
||||
buttons: Array.from({ length: buttonCount }, (_, idx) => createDefaultButton(idx + 1)),
|
||||
effects: {},
|
||||
};
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function assignNewReward(button, options = {}) {
|
||||
const allRewards = listRewards();
|
||||
if (!allRewards.length) throw new Error('No rewards configured');
|
||||
const excludeRewardId = typeof options.excludeRewardId === 'string' ? options.excludeRewardId : null;
|
||||
|
||||
const usedByOtherButtons = new Set(
|
||||
state.buttons
|
||||
.filter((entry) => entry.id !== button.id)
|
||||
.map((entry) => entry.rewardId)
|
||||
.filter((rewardId) => typeof rewardId === 'string' && rewardId.length > 0),
|
||||
);
|
||||
const uniqueCandidates = allRewards.filter(
|
||||
(reward) => !usedByOtherButtons.has(reward.id) && (!excludeRewardId || reward.id !== excludeRewardId),
|
||||
);
|
||||
const fallbackUniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id));
|
||||
const pool = uniqueCandidates.length
|
||||
? uniqueCandidates
|
||||
: fallbackUniqueCandidates.length
|
||||
? fallbackUniqueCandidates
|
||||
: allRewards.filter((reward) => !excludeRewardId || reward.id !== excludeRewardId);
|
||||
const reward = pool[Math.floor(Math.random() * pool.length)] || null;
|
||||
if (!reward) throw new Error('No rewards configured');
|
||||
|
||||
button.rewardId = reward.id;
|
||||
button.rewardName = reward.name || null;
|
||||
button.rewardNumber = reward.number;
|
||||
button.goal = reward.goal;
|
||||
button.count = 0;
|
||||
}
|
||||
|
||||
function ensureRewardAssignments() {
|
||||
if (!state) return;
|
||||
const seen = new Set();
|
||||
state.buttons.forEach((button) => {
|
||||
const reward = getRewardById(button.rewardId);
|
||||
if (reward && !seen.has(reward.id)) {
|
||||
seen.add(reward.id);
|
||||
button.rewardName = reward.name || null;
|
||||
button.rewardNumber = reward.number;
|
||||
button.goal = reward.goal;
|
||||
if (!Number.isFinite(button.count) || button.count < 0) {
|
||||
button.count = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
assignNewReward(button);
|
||||
if (button.rewardId) {
|
||||
seen.add(button.rewardId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLoaded(raw = {}) {
|
||||
const base = createDefaultState();
|
||||
const sourceButtons = Array.isArray(raw.buttons) ? raw.buttons : [];
|
||||
const buttons = base.buttons.map((button, idx) => {
|
||||
const loaded = sourceButtons[idx] || {};
|
||||
return {
|
||||
...button,
|
||||
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
|
||||
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
|
||||
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
|
||||
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
|
||||
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
|
||||
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
|
||||
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
||||
};
|
||||
});
|
||||
|
||||
const effects = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
|
||||
return {
|
||||
version: storeVersion,
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
||||
buttons,
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
function writeState() {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const next = {
|
||||
...state,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const tempPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, storePath);
|
||||
state = next;
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
if (state) return state;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||
state = normalizeLoaded(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load button box store', err.message);
|
||||
}
|
||||
state = createDefaultState();
|
||||
}
|
||||
ensureRewardAssignments();
|
||||
writeState();
|
||||
return state;
|
||||
}
|
||||
|
||||
function getState() {
|
||||
loadState();
|
||||
return state;
|
||||
}
|
||||
|
||||
function getStateClone() {
|
||||
return clone({ buttons: getState().buttons });
|
||||
}
|
||||
|
||||
return {
|
||||
loadState,
|
||||
writeState,
|
||||
getState,
|
||||
getStateClone,
|
||||
clone,
|
||||
assignNewReward,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createButtonBoxStore,
|
||||
};
|
||||
@@ -1,589 +0,0 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('chatService');
|
||||
const { publishEvent, subscribe } = require('./eventBus');
|
||||
const { getRole } = require('./roleService');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { describeAssignment } = require('./assignmentService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const { getAdminReason } = require('./adminReasonService');
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
const history = [];
|
||||
|
||||
// Words in this list are removed from the profanity dataset entirely.
|
||||
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
|
||||
const normalizedProfanityAllowlist = new Set(PROFANITY_ALLOWLIST
|
||||
.filter((term) => typeof term === 'string')
|
||||
.map((term) => term.trim().toLowerCase())
|
||||
.filter(Boolean));
|
||||
const profanityDataset = new DataSet()
|
||||
.addAll(englishDataset)
|
||||
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
|
||||
.build();
|
||||
const profanityMatcher = new RegExpMatcher({
|
||||
...profanityDataset,
|
||||
...englishRecommendedTransformers,
|
||||
whitelistedTerms: profanityDataset.whitelistedTerms,
|
||||
});
|
||||
const DUPLICATE_WINDOW_MS = 15000;
|
||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
||||
const typingBySocket = new Map(); // socketId -> boolean
|
||||
const TYPING_START_NOTE = 72;
|
||||
const TYPING_SEND_NOTE = 79;
|
||||
const TYPING_NOTE_DURATION = 8;
|
||||
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
|
||||
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
|
||||
let lastAccessNoticeAt = 0;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
const entries = rateBuckets.get(socketId) || [];
|
||||
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
|
||||
next.push(now);
|
||||
rateBuckets.set(socketId, next);
|
||||
return next.length <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
function hasProfanity(text) {
|
||||
if (typeof text !== 'string' || !text) return false;
|
||||
return profanityMatcher.hasMatch(text);
|
||||
}
|
||||
|
||||
function isDuplicate(socketId, text) {
|
||||
const prev = lastMessageBySocket.get(socketId);
|
||||
const now = Date.now();
|
||||
if (!prev) {
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return false;
|
||||
}
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function isKeymash(text) {
|
||||
if (!text) return false;
|
||||
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
|
||||
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
|
||||
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveRoverId(socketId) {
|
||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
if (primary) return primary;
|
||||
const assignment = describeAssignment(socketId);
|
||||
return assignment?.roverId || null;
|
||||
}
|
||||
|
||||
function resolveRoverColor(roverId) {
|
||||
if (!roverId) return null;
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
if (!roverId) return false;
|
||||
return roverManager.canReplayRoverId(roverId) !== true;
|
||||
}
|
||||
|
||||
function normalizeUserText(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function buildMessage(socket, text, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
socketId: socket?.id || null,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord: Boolean(meta.fromDiscord),
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
roverCtx: meta.roverCtx || null,
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
system: Boolean(meta.system),
|
||||
};
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
|
||||
return true;
|
||||
}
|
||||
const code = sensors?.chargingState?.code;
|
||||
return code === 2 || code === 3 || code === 4;
|
||||
}
|
||||
|
||||
function buildRoverCtxSnapshot(roverId) {
|
||||
if (!roverId) return null;
|
||||
const key = String(roverId);
|
||||
const record = roverManager.rovers.get(key);
|
||||
if (!record) return null;
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const batteryState = record?.batteryState || null;
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
|
||||
const charging = isChargingFromSensors(sensors);
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const wheelsOffGround = Boolean(
|
||||
sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight,
|
||||
);
|
||||
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
|
||||
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
|
||||
const latestBumps =
|
||||
(sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) +
|
||||
(sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
|
||||
const light = sensors?.lightBumper || {};
|
||||
const contactState = docked
|
||||
? 'clear'
|
||||
: latestBumps >= 0.5
|
||||
? 'bumps_recent'
|
||||
: sensors?.wall ||
|
||||
light.left ||
|
||||
light.frontLeft ||
|
||||
light.centerLeft ||
|
||||
light.centerRight ||
|
||||
light.frontRight ||
|
||||
light.right
|
||||
? 'wall_brush'
|
||||
: 'clear';
|
||||
const hazardState = docked
|
||||
? 'normal'
|
||||
: sensors?.virtualWall
|
||||
? 'virtual_wall_seen'
|
||||
: sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight
|
||||
? 'cliff_alert'
|
||||
: 'normal';
|
||||
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
|
||||
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
|
||||
const activityScore = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
baseScore +
|
||||
(contactState === 'wall_brush' ? 6 : 0) +
|
||||
(contactState === 'bumps_recent' ? 12 : 0) +
|
||||
(hazardState !== 'normal' ? 8 : 0) +
|
||||
(wheelsOffGround ? -20 : 0),
|
||||
),
|
||||
);
|
||||
const activityBand =
|
||||
activityScore >= 75
|
||||
? 'intense'
|
||||
: activityScore >= 50
|
||||
? 'high'
|
||||
: activityScore >= 25
|
||||
? 'medium'
|
||||
: activityScore >= 8
|
||||
? 'low'
|
||||
: 'idle';
|
||||
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
|
||||
let statusTag = 'idle';
|
||||
if (charging) {
|
||||
statusTag = 'charging';
|
||||
} else if (docked) {
|
||||
statusTag = 'docked';
|
||||
} else if (driverSocketId && moving) {
|
||||
statusTag = 'driving';
|
||||
} else if (driverSocketId) {
|
||||
statusTag = 'active-idle';
|
||||
}
|
||||
return {
|
||||
id: key,
|
||||
status_tag: statusTag,
|
||||
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
|
||||
docked,
|
||||
charging,
|
||||
wheels_off_ground: wheelsOffGround,
|
||||
contact_state: contactState,
|
||||
hazard_state: hazardState,
|
||||
mobility_state: mobilityState,
|
||||
activity_score: activityScore,
|
||||
activity_band: activityBand,
|
||||
activity_trend: 'steady',
|
||||
activity_30s: {
|
||||
distance_m: latestDistanceM,
|
||||
turn_deg: latestTurnDeg,
|
||||
bumps: latestBumps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const socketId = socket?.id || null;
|
||||
const fromDiscord = Boolean(meta.fromDiscord);
|
||||
let typingId = meta.typingId || null;
|
||||
if (!typingId) {
|
||||
if (fromDiscord) {
|
||||
if (meta.discordUserId) {
|
||||
typingId = `discord:${meta.discordUserId}`;
|
||||
} else if (meta.discordUserName) {
|
||||
typingId = `discord:${meta.discordUserName}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `discord:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'discord:unknown';
|
||||
}
|
||||
} else if (socketId) {
|
||||
typingId = `socket:${socketId}`;
|
||||
} else if (meta.nickname) {
|
||||
typingId = `socket:${meta.nickname}`;
|
||||
} else {
|
||||
typingId = 'socket:unknown';
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
typingId,
|
||||
isTyping: Boolean(meta.isTyping),
|
||||
socketId,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord,
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(message) {
|
||||
history.push(message);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentMessages(limit = 20, options = {}) {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||
const includeSystem = options?.includeSystem !== false;
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
||||
return source.slice(-safeLimit);
|
||||
}
|
||||
|
||||
function broadcastMessage(message) {
|
||||
pushHistory(message);
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
}
|
||||
|
||||
function broadcastTyping(payload) {
|
||||
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
||||
}
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: {
|
||||
notes: [{ note, duration: TYPING_NOTE_DURATION }],
|
||||
},
|
||||
});
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone sent', { roverId, note, socketId });
|
||||
} catch (err) {
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtsOptions(raw = {}) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const speak = raw.speak !== false;
|
||||
if (!speak) return null;
|
||||
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
|
||||
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
|
||||
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
|
||||
if (typeof pitch === 'number') {
|
||||
pitch = Math.max(0, Math.min(99, pitch));
|
||||
}
|
||||
return { speak, engine, voice, pitch };
|
||||
}
|
||||
|
||||
function buildAccessNoticeText(mode, reasonText) {
|
||||
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
|
||||
const reason = reasonText ? ` Reason: ${reasonText}` : '';
|
||||
return `Heads up: the server is in ${label} mode.${reason}`;
|
||||
}
|
||||
|
||||
function shouldSendAccessNotice(message) {
|
||||
if (!message?.text || message.system) return false;
|
||||
const mode = getMode();
|
||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||
const now = Date.now();
|
||||
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
|
||||
lastAccessNoticeAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendSystemMessage(text) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: 'The Overseer',
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
system: true,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function maybeSendAccessNotice(message) {
|
||||
if (!shouldSendAccessNotice(message)) return;
|
||||
const reason = getAdminReason()?.text || '';
|
||||
const mode = getMode();
|
||||
const notice = buildAccessNoticeText(mode, reason);
|
||||
sendSystemMessage(notice);
|
||||
}
|
||||
|
||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
// if (role === 'spectator') {
|
||||
// cb({ error: 'Spectators cannot chat' });
|
||||
// return;
|
||||
// }
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) {
|
||||
cb({ error: 'Message required' });
|
||||
return;
|
||||
}
|
||||
if (!withinRateLimit(socket.id)) {
|
||||
cb({ error: 'Slow down' });
|
||||
return;
|
||||
}
|
||||
if (clean.length > 400) {
|
||||
cb({ error: 'Message too long' });
|
||||
return;
|
||||
}
|
||||
if (hasProfanity(clean)) {
|
||||
cb({ error: 'Message blocked' });
|
||||
return;
|
||||
}
|
||||
// if (isDuplicate(socket.id, clean)) {
|
||||
// cb({ error: 'Duplicate message' });
|
||||
// return;
|
||||
// }
|
||||
// if (isKeymash(clean)) {
|
||||
// cb({ error: 'Message looks like spam' });
|
||||
// return;
|
||||
// }
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
const ttsOptions = normalizeTtsOptions(tts);
|
||||
const message = buildMessage(socket, clean, {
|
||||
fromDiscord: false,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
tts: ttsOptions,
|
||||
});
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
const privateClosed = isPrivateClosedRoverId(message.roverId);
|
||||
if (privateClosed) {
|
||||
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
|
||||
maybeSpeak(socket, message, forcedTts);
|
||||
cb({ success: true, privateOnly: true });
|
||||
return;
|
||||
}
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
if (!ttsOptions || !message?.roverId) return;
|
||||
const record = roverManager.rovers.get(message.roverId);
|
||||
const audio = record?.meta?.audio || {};
|
||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
const { isQueuedDriver } = require('./turnService');
|
||||
if (
|
||||
!roverManager.canDrive(message.roverId, socket) &&
|
||||
!isQueuedDriver(message.roverId, socket?.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
issueCommand(message.roverId, {
|
||||
type: 'tts',
|
||||
tts: {
|
||||
text: message.text,
|
||||
engine: ttsOptions.engine,
|
||||
voice: ttsOptions.voice,
|
||||
pitch: ttsOptions.pitch,
|
||||
speak: true,
|
||||
},
|
||||
});
|
||||
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
|
||||
} catch (err) {
|
||||
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function sendExternalMessage({
|
||||
text,
|
||||
nickname = 'Discord',
|
||||
role = 'admin',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean || clean.length > 400) {
|
||||
throw new Error('Message invalid');
|
||||
}
|
||||
if (hasProfanity(clean)) {
|
||||
throw new Error('Message blocked');
|
||||
}
|
||||
if (isKeymash(clean)) {
|
||||
throw new Error('Message looks like spam');
|
||||
}
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
throw new Error('Private rover chat is closed');
|
||||
}
|
||||
const message = buildMessage(null, clean, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
});
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function sendExternalTyping({
|
||||
nickname = 'Discord',
|
||||
role = 'user',
|
||||
roverId = null,
|
||||
discordGuildId = null,
|
||||
discordGuildName = null,
|
||||
discordGuildIconUrl = null,
|
||||
discordChannelId = null,
|
||||
discordUserId = null,
|
||||
discordUserName = null,
|
||||
discordUserAvatarUrl = null,
|
||||
isTyping = true,
|
||||
}) {
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return null;
|
||||
}
|
||||
const payload = buildTypingPayload(null, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
isTyping,
|
||||
});
|
||||
broadcastTyping(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.emit('chat:init', history);
|
||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||
socket.on('chat:typing', (payload = {}) => {
|
||||
const isTyping = Boolean(payload?.isTyping);
|
||||
const wasTyping = typingBySocket.get(socket.id);
|
||||
if (isTyping) {
|
||||
typingBySocket.set(socket.id, true);
|
||||
if (!wasTyping) {
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||
}
|
||||
} else {
|
||||
typingBySocket.delete(socket.id);
|
||||
}
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (!typingBySocket.has(socket.id)) return;
|
||||
typingBySocket.delete(socket.id);
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) {
|
||||
return;
|
||||
}
|
||||
const typingPayload = buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false });
|
||||
broadcastTyping(typingPayload);
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
|
||||
subscribe('chat:typing', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:typing', payload);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
getRecentMessages,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// Chat Broadcast Pipeline
|
||||
// Purpose: Publishes chat message and typing payloads to event bus while preserving in-memory history.
|
||||
// Scope: Encapsulates chat fan-out side effects and recent-history accessors.
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const { pushHistory, getRecentMessages } = require('./state');
|
||||
|
||||
function broadcastMessage(message) {
|
||||
pushHistory(message);
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
}
|
||||
|
||||
function broadcastTyping(payload) {
|
||||
publishEvent({ source: 'chat', type: 'chat:typing', payload });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
broadcastMessage,
|
||||
broadcastTyping,
|
||||
getRecentMessages,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// Chat Service Constants
|
||||
// Purpose: Defines chat moderation, history, and UX timing constants used across chat modules.
|
||||
// Scope: Centralizes immutable runtime tuning values for message and typing behavior.
|
||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const MAX_HISTORY = 100;
|
||||
const PROFANITY_ALLOWLIST = ['fuck', 'ass', 'shit'];
|
||||
const DUPLICATE_WINDOW_MS = 15000;
|
||||
const TYPING_START_NOTE = 72;
|
||||
const TYPING_SEND_NOTE = 79;
|
||||
const TYPING_NOTE_DURATION = 8;
|
||||
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
|
||||
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
|
||||
|
||||
module.exports = {
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
RATE_LIMIT_MAX,
|
||||
MAX_HISTORY,
|
||||
PROFANITY_ALLOWLIST,
|
||||
DUPLICATE_WINDOW_MS,
|
||||
TYPING_START_NOTE,
|
||||
TYPING_SEND_NOTE,
|
||||
TYPING_NOTE_DURATION,
|
||||
ACCESS_NOTICE_COOLDOWN_MS,
|
||||
ACCESS_KEYWORD_RE,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
// Chat Content Filters
|
||||
// Purpose: Provides profanity, spam, and text normalization helpers for incoming chat messages.
|
||||
// Scope: Contains pure filtering logic and duplicate/keymash detectors.
|
||||
const { DataSet, RegExpMatcher, englishDataset, englishRecommendedTransformers } = require('obscenity');
|
||||
const { PROFANITY_ALLOWLIST, DUPLICATE_WINDOW_MS } = require('./constants');
|
||||
const { lastMessageBySocket } = require('./state');
|
||||
|
||||
const normalizedProfanityAllowlist = new Set(
|
||||
PROFANITY_ALLOWLIST
|
||||
.filter((term) => typeof term === 'string')
|
||||
.map((term) => term.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const profanityDataset = new DataSet()
|
||||
.addAll(englishDataset)
|
||||
.removePhrasesIf((phrase) => normalizedProfanityAllowlist.has(phrase.metadata?.originalWord))
|
||||
.build();
|
||||
|
||||
const profanityMatcher = new RegExpMatcher({
|
||||
...profanityDataset,
|
||||
...englishRecommendedTransformers,
|
||||
whitelistedTerms: profanityDataset.whitelistedTerms,
|
||||
});
|
||||
|
||||
function hasProfanity(text) {
|
||||
if (typeof text !== 'string' || !text) return false;
|
||||
return profanityMatcher.hasMatch(text);
|
||||
}
|
||||
|
||||
function isDuplicate(socketId, text) {
|
||||
const prev = lastMessageBySocket.get(socketId);
|
||||
const now = Date.now();
|
||||
if (!prev) {
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return false;
|
||||
}
|
||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
||||
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function isKeymash(text) {
|
||||
if (!text) return false;
|
||||
if (/(.)\1{6,}/.test(text)) return true;
|
||||
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
|
||||
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeUserText(raw) {
|
||||
if (typeof raw !== 'string') return '';
|
||||
return raw.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasProfanity,
|
||||
isDuplicate,
|
||||
isKeymash,
|
||||
normalizeUserText,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
// Chat Context Builders
|
||||
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
||||
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../../globals/io');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRole } = require('../roleService');
|
||||
const { describeAssignment } = require('../assignmentService');
|
||||
const { getNickname } = require('../nicknameService');
|
||||
|
||||
function resolveRoverId(socketId) {
|
||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
if (primary) return primary;
|
||||
const assignment = describeAssignment(socketId);
|
||||
return assignment?.roverId || null;
|
||||
}
|
||||
|
||||
function resolveRoverColor(roverId) {
|
||||
if (!roverId) return null;
|
||||
const record = roverManager.rovers.get(String(roverId));
|
||||
return record?.meta?.color || null;
|
||||
}
|
||||
|
||||
function isPrivateClosedRoverId(roverId) {
|
||||
if (!roverId) return false;
|
||||
return roverManager.canReplayRoverId(roverId) !== true;
|
||||
}
|
||||
|
||||
function normalizeProfileImageUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const protocol = String(parsed.protocol || '').toLowerCase();
|
||||
if (protocol !== 'http:' && protocol !== 'https:') return null;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
||||
const code = sensors?.chargingState?.code;
|
||||
return code === 2 || code === 3 || code === 4;
|
||||
}
|
||||
|
||||
function buildRoverCtxSnapshot(roverId) {
|
||||
if (!roverId) return null;
|
||||
const key = String(roverId);
|
||||
const record = roverManager.rovers.get(key);
|
||||
if (!record) return null;
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const batteryState = record?.batteryState || null;
|
||||
const { getActiveDrivers } = require('../turnService');
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const driverSocketId = activeDrivers[key] || record?.drivers?.values?.().next?.().value || null;
|
||||
const charging = isChargingFromSensors(sensors);
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const wheelsOffGround = Boolean(sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight);
|
||||
const latestDistanceM = Math.round((Math.abs(Number(sensors?.distanceMm) || 0) / 1000) * 10) / 10;
|
||||
const latestTurnDeg = Math.round(Math.abs(Number(sensors?.angleDeg) || 0));
|
||||
const latestBumps = (sensors?.bumpsAndWheelDrops?.bumpLeft ? 0.5 : 0) + (sensors?.bumpsAndWheelDrops?.bumpRight ? 0.5 : 0);
|
||||
const light = sensors?.lightBumper || {};
|
||||
const contactState = docked ? 'clear' : latestBumps >= 0.5 ? 'bumps_recent' : sensors?.wall || light.left || light.frontLeft || light.centerLeft || light.centerRight || light.frontRight || light.right ? 'wall_brush' : 'clear';
|
||||
const hazardState = docked ? 'normal' : sensors?.virtualWall ? 'virtual_wall_seen' : sensors?.cliffLeft || sensors?.cliffFrontLeft || sensors?.cliffFrontRight || sensors?.cliffRight ? 'cliff_alert' : 'normal';
|
||||
const mobilityState = wheelsOffGround ? 'wheels_off_ground' : 'normal';
|
||||
const baseScore = Math.min(100, Math.round(Math.min(45, latestDistanceM * 25) + Math.min(30, latestTurnDeg / 12) + Math.min(25, latestBumps * 12)));
|
||||
const activityScore = Math.max(0, Math.min(100, baseScore + (contactState === 'wall_brush' ? 6 : 0) + (contactState === 'bumps_recent' ? 12 : 0) + (hazardState !== 'normal' ? 8 : 0) + (wheelsOffGround ? -20 : 0)));
|
||||
const activityBand = activityScore >= 75 ? 'intense' : activityScore >= 50 ? 'high' : activityScore >= 25 ? 'medium' : activityScore >= 8 ? 'low' : 'idle';
|
||||
const moving = latestDistanceM > 0.05 || latestTurnDeg > 10;
|
||||
let statusTag = 'idle';
|
||||
if (charging) statusTag = 'charging';
|
||||
else if (docked) statusTag = 'docked';
|
||||
else if (driverSocketId && moving) statusTag = 'driving';
|
||||
else if (driverSocketId) statusTag = 'active-idle';
|
||||
|
||||
return {
|
||||
id: key,
|
||||
status_tag: statusTag,
|
||||
battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive),
|
||||
docked,
|
||||
charging,
|
||||
wheels_off_ground: wheelsOffGround,
|
||||
contact_state: contactState,
|
||||
hazard_state: hazardState,
|
||||
mobility_state: mobilityState,
|
||||
activity_score: activityScore,
|
||||
activity_band: activityBand,
|
||||
activity_trend: 'steady',
|
||||
activity_30s: {
|
||||
distance_m: latestDistanceM,
|
||||
turn_deg: latestTurnDeg,
|
||||
bumps: latestBumps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessage(socket, text, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
socketId: socket?.id || null,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord: Boolean(meta.fromDiscord),
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
profileImage: normalizeProfileImageUrl(meta.profileImage),
|
||||
roverCtx: meta.roverCtx || null,
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
bot: Boolean(meta.bot),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypingPayload(socket, meta = {}) {
|
||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||
const socketId = socket?.id || null;
|
||||
const fromDiscord = Boolean(meta.fromDiscord);
|
||||
let typingId = meta.typingId || null;
|
||||
if (!typingId) {
|
||||
if (fromDiscord) {
|
||||
if (meta.discordUserId) typingId = `discord:${meta.discordUserId}`;
|
||||
else if (meta.discordUserName) typingId = `discord:${meta.discordUserName}`;
|
||||
else if (meta.nickname) typingId = `discord:${meta.nickname}`;
|
||||
else typingId = 'discord:unknown';
|
||||
} else if (socketId) typingId = `socket:${socketId}`;
|
||||
else if (meta.nickname) typingId = `socket:${meta.nickname}`;
|
||||
else typingId = 'socket:unknown';
|
||||
}
|
||||
return {
|
||||
id: uuidv4(),
|
||||
ts: Date.now(),
|
||||
typingId,
|
||||
isTyping: Boolean(meta.isTyping),
|
||||
socketId,
|
||||
nickname: meta.nickname || getNickname(socket) || null,
|
||||
role: meta.role || getRole(socket),
|
||||
roverId,
|
||||
roverColor,
|
||||
fromDiscord,
|
||||
discordGuildId: meta.discordGuildId || null,
|
||||
discordGuildName: meta.discordGuildName || null,
|
||||
discordGuildIconUrl: meta.discordGuildIconUrl || null,
|
||||
discordChannelId: meta.discordChannelId || null,
|
||||
discordUserId: meta.discordUserId || null,
|
||||
discordUserName: meta.discordUserName || null,
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveRoverId,
|
||||
isPrivateClosedRoverId,
|
||||
buildRoverCtxSnapshot,
|
||||
buildMessage,
|
||||
buildTypingPayload,
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
// Chat Message Handlers
|
||||
// Purpose: Handles inbound socket/external chat payloads and applies moderation, routing, and side effects.
|
||||
// Scope: Owns message validation pipeline and typed outbound message construction.
|
||||
const logger = require('../../globals/logger').child('chatService');
|
||||
const { getRole } = require('../roleService');
|
||||
const { withinRateLimit } = require('./state');
|
||||
const { hasProfanity, isKeymash, normalizeUserText } = require('./contentFilters');
|
||||
const { buildMessage, buildTypingPayload, resolveRoverId, isPrivateClosedRoverId, buildRoverCtxSnapshot } = require('./contextBuilders');
|
||||
const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||
|
||||
function createHandlers({ sendSystemMessage }) {
|
||||
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
void role;
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return cb({ error: 'Message required' });
|
||||
if (!withinRateLimit(socket.id)) return cb({ error: 'Slow down' });
|
||||
if (clean.length > 400) return cb({ error: 'Message too long' });
|
||||
if (hasProfanity(clean)) return cb({ error: 'Message blocked' });
|
||||
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
const ttsOptions = normalizeTtsOptions(tts);
|
||||
const message = buildMessage(socket, clean, {
|
||||
fromDiscord: false,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
tts: ttsOptions,
|
||||
bot,
|
||||
profileImage,
|
||||
});
|
||||
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
|
||||
if (isPrivateClosedRoverId(message.roverId)) {
|
||||
const forcedTts = ttsOptions || { speak: true, engine: 'flite' };
|
||||
maybeSpeak(socket, message, forcedTts);
|
||||
cb({ success: true, privateOnly: true });
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message, sendSystemMessage);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean || clean.length > 400) throw new Error('Message invalid');
|
||||
if (hasProfanity(clean)) throw new Error('Message blocked');
|
||||
if (isKeymash(clean)) throw new Error('Message looks like spam');
|
||||
if (isPrivateClosedRoverId(roverId)) throw new Error('Private rover chat is closed');
|
||||
|
||||
const message = buildMessage(null, clean, {
|
||||
nickname,
|
||||
role,
|
||||
roverId,
|
||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||
fromDiscord: true,
|
||||
discordGuildId,
|
||||
discordGuildName,
|
||||
discordGuildIconUrl,
|
||||
discordChannelId,
|
||||
discordUserId,
|
||||
discordUserName,
|
||||
discordUserAvatarUrl,
|
||||
bot,
|
||||
profileImage,
|
||||
});
|
||||
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message, sendSystemMessage);
|
||||
return message;
|
||||
}
|
||||
|
||||
function sendExternalTyping({ nickname = 'Discord', role = 'user', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, isTyping = true }) {
|
||||
if (isPrivateClosedRoverId(roverId)) return null;
|
||||
const payload = buildTypingPayload(null, { nickname, role, roverId, fromDiscord: true, discordGuildId, discordGuildName, discordGuildIconUrl, discordChannelId, discordUserId, discordUserName, discordUserAvatarUrl, isTyping });
|
||||
broadcastTyping(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createHandlers,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
// Chat Service Orchestrator
|
||||
// Purpose: Composes chat submodules into the public service API and boots socket/event wiring.
|
||||
// Scope: Keeps external chat contracts stable while delegating logic to focused modules.
|
||||
const { history } = require('./state');
|
||||
const { normalizeUserText } = require('./contentFilters');
|
||||
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
|
||||
const { broadcastMessage, getRecentMessages } = require('./broadcast');
|
||||
const { createHandlers } = require('./handlers');
|
||||
const { registerChatSocketHooks } = require('./socketHooks');
|
||||
|
||||
function sendSystemMessage(text, options = {}) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const safe = clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: String(options.nickname || 'The Overseer'),
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
bot: options.bot !== false,
|
||||
profileImage: options.profileImage || null,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
const { handleIncoming, sendExternalMessage, sendExternalTyping } = createHandlers({ sendSystemMessage });
|
||||
|
||||
registerChatSocketHooks({ history, handleIncoming });
|
||||
|
||||
module.exports = {
|
||||
handleIncoming,
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
getRecentMessages,
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
// Chat Rover Notifications
|
||||
// Purpose: Sends rover typing tones, optional rover TTS, and access-mode system notices.
|
||||
// Scope: Owns rover-side notification effects derived from chat activity.
|
||||
const logger = require('../../globals/logger').child('chatService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { getRole } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const { getAdminReason } = require('../adminReasonService');
|
||||
const {
|
||||
TYPING_NOTE_DURATION,
|
||||
ACCESS_NOTICE_COOLDOWN_MS,
|
||||
ACCESS_KEYWORD_RE,
|
||||
TYPING_START_NOTE,
|
||||
TYPING_SEND_NOTE,
|
||||
} = require('./constants');
|
||||
const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
||||
|
||||
function playTypingNote(roverId, note, socketId) {
|
||||
if (!roverId) return;
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: { notes: [{ note, duration: TYPING_NOTE_DURATION }] },
|
||||
});
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone sent', { roverId, note, socketId });
|
||||
} catch (err) {
|
||||
const log = typeof logger.debug === 'function' ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
log('Typing tone failed', { roverId, note, socketId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtsOptions(raw = {}) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const speak = raw.speak !== false;
|
||||
if (!speak) return null;
|
||||
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
|
||||
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
|
||||
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
|
||||
if (typeof pitch === 'number') pitch = Math.max(0, Math.min(99, pitch));
|
||||
return { speak, engine, voice, pitch };
|
||||
}
|
||||
|
||||
function buildAccessNoticeText(mode, reasonText) {
|
||||
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
|
||||
const reason = reasonText ? ` Reason: ${reasonText}` : '';
|
||||
return `Heads up: the server is in ${label} mode.${reason}`;
|
||||
}
|
||||
|
||||
function shouldSendAccessNotice(message) {
|
||||
if (!message?.text || message.bot) return false;
|
||||
const mode = getMode();
|
||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||
const now = Date.now();
|
||||
if (now - getLastAccessNoticeAt() < ACCESS_NOTICE_COOLDOWN_MS) return false;
|
||||
setLastAccessNoticeAt(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeSendAccessNotice(message, sendSystemMessage) {
|
||||
if (!shouldSendAccessNotice(message)) return;
|
||||
const reason = getAdminReason()?.text || '';
|
||||
const notice = buildAccessNoticeText(getMode(), reason);
|
||||
sendSystemMessage(notice);
|
||||
}
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
if (!ttsOptions || !message?.roverId) return;
|
||||
const record = roverManager.rovers.get(message.roverId);
|
||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
const { isQueuedDriver } = require('../turnService');
|
||||
if (!roverManager.canDrive(message.roverId, socket) && !isQueuedDriver(message.roverId, socket?.id)) return;
|
||||
try {
|
||||
issueCommand(message.roverId, {
|
||||
type: 'tts',
|
||||
tts: {
|
||||
text: message.text,
|
||||
engine: ttsOptions.engine,
|
||||
voice: ttsOptions.voice,
|
||||
pitch: ttsOptions.pitch,
|
||||
speak: true,
|
||||
},
|
||||
});
|
||||
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
|
||||
} catch (err) {
|
||||
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
playTypingNote,
|
||||
normalizeTtsOptions,
|
||||
maybeSendAccessNotice,
|
||||
maybeSpeak,
|
||||
TYPING_START_NOTE,
|
||||
TYPING_SEND_NOTE,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// Chat Socket Hooks
|
||||
// Purpose: Registers chat socket handlers and event-bus fan-out listeners.
|
||||
// Scope: Bridges socket events to chat handlers and publishes chat updates to connected clients.
|
||||
const io = require('../../globals/io');
|
||||
const { subscribe } = require('../eventBus');
|
||||
const { typingBySocket } = require('./state');
|
||||
const { buildTypingPayload, resolveRoverId, isPrivateClosedRoverId } = require('./contextBuilders');
|
||||
const { broadcastTyping } = require('./broadcast');
|
||||
const { playTypingNote, TYPING_START_NOTE } = require('./notifications');
|
||||
|
||||
function registerChatSocketHooks({ history, handleIncoming }) {
|
||||
io.on('connection', (socket) => {
|
||||
socket.emit('chat:init', history);
|
||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
||||
socket.on('chat:typing', (payload = {}) => {
|
||||
const isTyping = Boolean(payload?.isTyping);
|
||||
const wasTyping = typingBySocket.get(socket.id);
|
||||
if (isTyping) {
|
||||
typingBySocket.set(socket.id, true);
|
||||
if (!wasTyping) {
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
playTypingNote(roverId, TYPING_START_NOTE, socket?.id);
|
||||
}
|
||||
} else {
|
||||
typingBySocket.delete(socket.id);
|
||||
}
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) return;
|
||||
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping }));
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
if (!typingBySocket.has(socket.id)) return;
|
||||
typingBySocket.delete(socket.id);
|
||||
const roverId = resolveRoverId(socket?.id);
|
||||
if (isPrivateClosedRoverId(roverId)) return;
|
||||
broadcastTyping(buildTypingPayload(socket, { roverId, fromDiscord: false, isTyping: false }));
|
||||
});
|
||||
});
|
||||
|
||||
subscribe('chat:message', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:message', payload);
|
||||
});
|
||||
|
||||
subscribe('chat:typing', ({ payload }) => {
|
||||
if (!payload) return;
|
||||
io.emit('chat:typing', payload);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerChatSocketHooks,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// Chat Service State
|
||||
// Purpose: Stores mutable chat runtime state for rate limits, history, and typing bookkeeping.
|
||||
// Scope: Encapsulates in-memory collections shared by chat service modules.
|
||||
const { RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX, MAX_HISTORY } = require('./constants');
|
||||
|
||||
const rateBuckets = new Map();
|
||||
const history = [];
|
||||
const lastMessageBySocket = new Map();
|
||||
const typingBySocket = new Map();
|
||||
let lastAccessNoticeAt = 0;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
const entries = rateBuckets.get(socketId) || [];
|
||||
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
|
||||
next.push(now);
|
||||
rateBuckets.set(socketId, next);
|
||||
return next.length <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
function pushHistory(message) {
|
||||
history.push(message);
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentMessages(limit = 20, options = {}) {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||
const includeSystem = options?.includeSystem !== false;
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
|
||||
return source.slice(-safeLimit);
|
||||
}
|
||||
|
||||
function getLastAccessNoticeAt() {
|
||||
return lastAccessNoticeAt;
|
||||
}
|
||||
|
||||
function setLastAccessNoticeAt(ts) {
|
||||
lastAccessNoticeAt = ts;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rateBuckets,
|
||||
history,
|
||||
lastMessageBySocket,
|
||||
typingBySocket,
|
||||
withinRateLimit,
|
||||
pushHistory,
|
||||
getRecentMessages,
|
||||
getLastAccessNoticeAt,
|
||||
setLastAccessNoticeAt,
|
||||
};
|
||||
+23
-6
@@ -1,8 +1,13 @@
|
||||
// command Service
|
||||
// Purpose: Defines the command Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const io = require('../globals/io');
|
||||
const roverManager = require('./roverManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('./roleService');
|
||||
const logger = require('../globals/logger').child('commandService');
|
||||
const io = require('../../globals/io');
|
||||
const roverManager = require('../roverManager');
|
||||
const { isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const { isDeterred } = require('../verificationService');
|
||||
const logger = require('../../globals/logger').child('commandService');
|
||||
const { isNightVisionBlocked } = require('../../rewards/definitions/darkness');
|
||||
|
||||
const pendingCommands = new Map(); // id -> { roverId }
|
||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||
@@ -70,10 +75,18 @@ io.on('connection', (socket) => {
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
if (type === 'nightVision' && isNightVisionBlocked()) {
|
||||
logger.info('Ignoring night vision command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
return;
|
||||
}
|
||||
const payload = data ? { ...data } : {};
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (!isAdminSocket && isDeterred(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (isRebootCommand && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
@@ -81,14 +94,17 @@ io.on('connection', (socket) => {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||
if (type === 'drive' && driveDirect) {
|
||||
if (!isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
}
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
if (!isAdminSocket) {
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
@@ -96,6 +112,7 @@ io.on('connection', (socket) => {
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
}
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
@@ -112,7 +129,7 @@ io.on('connection', (socket) => {
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
try {
|
||||
const { recordActivity } = require('./turnService');
|
||||
const { recordActivity } = require('../turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// best effort; ignore activity update errors
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user