mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b87b1127 | ||
|
|
796613876b | ||
|
|
742232b882 | ||
|
|
f56d865c8e | ||
|
|
7e7b07083a | ||
|
|
d72bc0db03 | ||
|
|
e9989da71a | ||
|
|
331302da01 | ||
|
|
bfd1a94030 | ||
|
|
8727555788 | ||
|
|
b62af19418 | ||
|
|
4af93fe05a | ||
|
|
f9f2e1e2ed | ||
|
|
78c7e44d63 | ||
|
|
ddb03bc809 | ||
|
|
2da58920c4 | ||
|
|
1e5f282a6a | ||
|
|
064aaee76c | ||
|
|
3a2c1bee35 | ||
|
|
e11361b5d5 | ||
|
|
03929822e6 | ||
|
|
3ce9ea2cdc | ||
|
|
daa8942a29 | ||
|
|
8c2162d918 | ||
|
|
ab32b0416c | ||
|
|
3ce2bdc541 | ||
|
|
bac957dfd6 | ||
|
|
2ad6f5e6d4 | ||
|
|
18c70b01c5 | ||
|
|
51d8c72ae8 | ||
|
|
084dee4df9 | ||
|
|
fb65926760 | ||
|
|
6c3ac6d22d | ||
|
|
4d179b4372 | ||
|
|
b8d00e063f | ||
|
|
a109c49a5b | ||
|
|
faac11e3fc | ||
|
|
133ea3f6d2 | ||
|
|
f9e472c832 | ||
|
|
e432e3de79 | ||
|
|
8a7be00bf6 | ||
|
|
372c5dee70 | ||
|
|
135ee78a5d | ||
|
|
84e5cbc787 | ||
|
|
f9c4249086 | ||
|
|
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 |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -135,6 +135,8 @@ run_pipeline() {
|
|||||||
-c:v copy \
|
-c:v copy \
|
||||||
-an \
|
-an \
|
||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
|
-muxdelay 0 \
|
||||||
|
-muxpreload 0 \
|
||||||
-f mpegts \
|
-f mpegts \
|
||||||
"${PUBLISH_URL}"
|
"${PUBLISH_URL}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
autoChargeTimeout = 5 * time.Second
|
autoChargeTimeout = 1 * time.Second
|
||||||
autoChargeCooldown = 0 * time.Minute
|
autoChargeCooldown = 0 * time.Minute
|
||||||
sourceHomeBase = 1 << 1
|
sourceHomeBase = 1 << 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package roverd
|
|||||||
type helloMessage struct {
|
type helloMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
Battery BatteryConfig `json:"battery"`
|
Battery BatteryConfig `json:"battery"`
|
||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ type PrivateSafetyConfig struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description" json:"description,omitempty"`
|
||||||
Color string `yaml:"color" json:"color,omitempty"`
|
Color string `yaml:"color" json:"color,omitempty"`
|
||||||
ServerURL string `yaml:"serverUrl"`
|
ServerURL string `yaml:"serverUrl"`
|
||||||
Serial SerialConfig `yaml:"serial"`
|
Serial SerialConfig `yaml:"serial"`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Sample configuration for roverd
|
# Sample configuration for roverd
|
||||||
name: roomba-alpha
|
name: roomba-alpha
|
||||||
|
description: "Loves corners, hates cords."
|
||||||
color: "#4DB6AC"
|
color: "#4DB6AC"
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
serial:
|
serial:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Sample configuration for roverd
|
# Sample configuration for roverd
|
||||||
name: roomba-alpha
|
name: roomba-alpha
|
||||||
|
description: "Loves corners, hates cords."
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyAMA0
|
device: /dev/ttyAMA0
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|||||||
msg := helloMessage{
|
msg := helloMessage{
|
||||||
Type: "hello",
|
Type: "hello",
|
||||||
Name: c.cfg.Name,
|
Name: c.cfg.Name,
|
||||||
|
Description: c.cfg.Description,
|
||||||
Color: c.cfg.Color,
|
Color: c.cfg.Color,
|
||||||
Battery: c.cfg.Battery,
|
Battery: c.cfg.Battery,
|
||||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
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,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.
|
||||||
+9
-16
@@ -1,19 +1,12 @@
|
|||||||
1. fix controls remapping [x]
|
1. FIX NO AUDIO DURING NOT YOUR TURN!!!
|
||||||
2. trusted user system [x]
|
2. make sure audio forwarding doesnt stop when you switch tabs
|
||||||
3. private rovers [x]
|
3. add "reboot your own rover" feature
|
||||||
4. optional bump-off in drive macro [x]
|
1.
|
||||||
5. allow admins to click on locked rovers from the roster [x]
|
4. add faster way for admins to login, like an invisible button in top left or something
|
||||||
6. add faster way for admins to login
|
5. add discord bot typing thing for when someone requests a replay
|
||||||
7. custom webhook profile pictures for chat bridge in discord
|
6. change replay title for ones requested from discord, something other than "requester driving rover"
|
||||||
8. home assistant switch that tells the server to force the lights on
|
7. fix rover request spam queue cheat
|
||||||
9. color coding with colored names and tape [x]
|
|
||||||
10. audio forwarding [x]
|
|
||||||
- streaming from server to rovers [x]
|
|
||||||
- audio files first [x]
|
|
||||||
- then voice chat [x]
|
|
||||||
10. mobile controls column swapping (optional joystick on left) [x]
|
|
||||||
11. fix fullscreen on mobile so that you can re-enter it [x]
|
|
||||||
12. home assistant rover mute switch
|
|
||||||
|
|
||||||
# relative pipe dreams:
|
# relative pipe dreams:
|
||||||
1. VPS video forwarding
|
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
|
||||||
@@ -1 +1,42 @@
|
|||||||
# RULES FOR LOCKDOWN AUDIT
|
# 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.
|
||||||
@@ -13,6 +13,18 @@ llmCommentary:
|
|||||||
model: "qwen2.5:7b-instruct"
|
model: "qwen2.5:7b-instruct"
|
||||||
ollamaServer: "http://127.0.0.1:11434"
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
frequency: 120000
|
frequency: 120000
|
||||||
|
overseerControl:
|
||||||
|
enabled: false
|
||||||
|
observeOnly: true
|
||||||
|
alwaysRunModel: false
|
||||||
|
postToolsOnlyMessages: 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
|
||||||
|
postChatDelayMs: 20000
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
@@ -38,12 +50,6 @@ homeAssistant:
|
|||||||
# ESPHome device name, used to derive gen3 entities:
|
# ESPHome device name, used to derive gen3 entities:
|
||||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
||||||
device: "neato_vacuum"
|
device: "neato_vacuum"
|
||||||
# Direct ESPHome API connection used for lidar log streaming.
|
|
||||||
brainslugHost: "neato-vacuum.local"
|
|
||||||
brainslugPort: 6053
|
|
||||||
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
|
||||||
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
|
|
||||||
brainslugLogFile: "/tmp/brainslug-lidar.log"
|
|
||||||
lift:
|
lift:
|
||||||
# Two Home Assistant switches controlling lift direction.
|
# Two Home Assistant switches controlling lift direction.
|
||||||
# Raise sequence: down off -> wait interlockMs -> up on
|
# Raise sequence: down off -> wait interlockMs -> up on
|
||||||
@@ -114,12 +120,20 @@ socials:
|
|||||||
- id: "discord"
|
- id: "discord"
|
||||||
label: "Discord"
|
label: "Discord"
|
||||||
url: "https://discord.gg/your-invite"
|
url: "https://discord.gg/your-invite"
|
||||||
|
icon: "FaDiscord"
|
||||||
|
color: "#5865F2"
|
||||||
- id: "kofi"
|
- id: "kofi"
|
||||||
label: "Ko-fi"
|
label: "Ko-fi"
|
||||||
url: "https://ko-fi.com/your-handle"
|
url: "https://ko-fi.com/your-handle"
|
||||||
|
icon: "FaCoffee"
|
||||||
|
color: "#29ABE0"
|
||||||
- id: "wiki"
|
- id: "wiki"
|
||||||
label: "Wiki"
|
label: "Wiki"
|
||||||
url: "https://wiki.example.com"
|
url: "https://wiki.example.com"
|
||||||
|
icon: "FaBook"
|
||||||
|
color: "#475569"
|
||||||
- id: "throne"
|
- id: "throne"
|
||||||
label: "Throne"
|
label: "Throne"
|
||||||
url: "https://throne.me/yourname"
|
url: "https://throne.me/yourname"
|
||||||
|
icon: "FaCrown"
|
||||||
|
color: "#334155"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ require('./src/services/verificationService');
|
|||||||
require('./src/services/privateRoverAccessRequestService');
|
require('./src/services/privateRoverAccessRequestService');
|
||||||
require('./src/services/chatService');
|
require('./src/services/chatService');
|
||||||
require('./src/services/llmCommentaryService');
|
require('./src/services/llmCommentaryService');
|
||||||
|
require('./src/services/overseerControlService');
|
||||||
require('./src/services/globalObjectiveService');
|
require('./src/services/globalObjectiveService');
|
||||||
require('./src/services/serverControlService');
|
require('./src/services/serverControlService');
|
||||||
require('./src/services/videoSessions');
|
require('./src/services/videoSessions');
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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:
|
||||||
|
- Do NOT respond to something again if you have already responded to it.
|
||||||
|
- Each cycle is autonomous; do not wait for permission from metadata.
|
||||||
|
- Don't respond or use tools every cycle unless the context calls for it.
|
||||||
|
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
|
||||||
|
- Use tool calls for actions and memory updates.
|
||||||
|
- NEVER put tool calls in your chat output.
|
||||||
|
- 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.
|
||||||
|
- It is not possible for you to control the rovers.
|
||||||
|
- All rovers are female. Neato is nonbinary.
|
||||||
|
|
||||||
|
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="theme-color" content="#020617" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
|
<script type="module" crossorigin src="/assets/index-CboMb02p.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BGy4iG0w.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Reward Definition: Light Strobe
|
// 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.
|
// 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 STROBE_MS = 30 * 1000;
|
||||||
const TICK_MS = 70;
|
const TICK_MS = 200;
|
||||||
|
|
||||||
let activeTimer = null;
|
let activeTimer = null;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const lightStrobe = require('./definitions/lightStrobe');
|
|||||||
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
||||||
const darkness = require('./definitions/darkness');
|
const darkness = require('./definitions/darkness');
|
||||||
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
||||||
|
const discordPingEveryone = require('./definitions/discordPingEveryone');
|
||||||
const modeJam = require('./definitions/modeJam');
|
const modeJam = require('./definitions/modeJam');
|
||||||
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
||||||
const chatSpam = require('./definitions/chatSpam');
|
const chatSpam = require('./definitions/chatSpam');
|
||||||
@@ -17,6 +18,7 @@ const orderedRewards = [
|
|||||||
ghostTypingSpam,
|
ghostTypingSpam,
|
||||||
darkness,
|
darkness,
|
||||||
discordStalkerPing,
|
discordStalkerPing,
|
||||||
|
discordPingEveryone,
|
||||||
modeJam,
|
modeJam,
|
||||||
assignmentRoulette,
|
assignmentRoulette,
|
||||||
chatSpam,
|
chatSpam,
|
||||||
|
|||||||
@@ -142,6 +142,32 @@ function createButtonBoxCore(deps) {
|
|||||||
return store.clone(button);
|
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() {
|
async function recoverEffects() {
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
||||||
@@ -165,6 +191,7 @@ function createButtonBoxCore(deps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
applyPress,
|
applyPress,
|
||||||
|
addCount,
|
||||||
recoverEffects,
|
recoverEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ function registerButtonBoxRoute(deps) {
|
|||||||
app,
|
app,
|
||||||
logger,
|
logger,
|
||||||
buttonCount,
|
buttonCount,
|
||||||
getRequestIp,
|
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
applyPress,
|
applyPress,
|
||||||
@@ -21,8 +20,16 @@ function registerButtonBoxRoute(deps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSocketIp(req) {
|
||||||
|
return (
|
||||||
|
req?.socket?.remoteAddress ||
|
||||||
|
req?.connection?.remoteAddress ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function denyIfNotLocal(req, res) {
|
function denyIfNotLocal(req, res) {
|
||||||
const ip = normalizeIp(getRequestIp(req));
|
const ip = normalizeIp(getSocketIp(req));
|
||||||
if (isLocalNetwork(ip)) {
|
if (isLocalNetwork(ip)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const {
|
|||||||
setEntityState: setHomeAssistantEntityState,
|
setEntityState: setHomeAssistantEntityState,
|
||||||
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
setLightsLockedOn: setHomeAssistantLightsLockedOn,
|
||||||
} = require('../homeAssistantService');
|
} = require('../homeAssistantService');
|
||||||
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
|
||||||
const { createButtonBoxStore } = require('./store');
|
const { createButtonBoxStore } = require('./store');
|
||||||
const { createButtonBoxCore } = require('./core');
|
const { createButtonBoxCore } = require('./core');
|
||||||
const { registerButtonBoxRoute } = require('./httpRoute');
|
const { registerButtonBoxRoute } = require('./httpRoute');
|
||||||
@@ -66,7 +66,6 @@ registerButtonBoxRoute({
|
|||||||
app,
|
app,
|
||||||
logger,
|
logger,
|
||||||
buttonCount: BUTTON_COUNT,
|
buttonCount: BUTTON_COUNT,
|
||||||
getRequestIp,
|
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
applyPress: core.applyPress,
|
applyPress: core.applyPress,
|
||||||
@@ -79,4 +78,5 @@ core.recoverEffects().catch((err) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getButtonBoxState: store.getStateClone,
|
getButtonBoxState: store.getStateClone,
|
||||||
|
addButtonBoxCount: core.addCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ function isPrivateClosedRoverId(roverId) {
|
|||||||
return roverManager.canReplayRoverId(roverId) !== true;
|
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 = {}) {
|
function isChargingFromSensors(sensors = {}) {
|
||||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
|
||||||
@@ -87,6 +100,21 @@ function buildRoverCtxSnapshot(roverId) {
|
|||||||
function buildMessage(socket, text, meta = {}) {
|
function buildMessage(socket, text, meta = {}) {
|
||||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||||
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||||
|
const toolCalls = Array.isArray(meta.toolCalls)
|
||||||
|
? meta.toolCalls
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
return {
|
||||||
|
tool: String(entry.tool || '').trim() || 'unknown',
|
||||||
|
status: String(entry.status || '').trim() || 'unknown',
|
||||||
|
args: entry.args && typeof entry.args === 'object' ? entry.args : {},
|
||||||
|
result: entry.result && typeof entry.result === 'object' ? entry.result : null,
|
||||||
|
error: entry.error ? String(entry.error) : null,
|
||||||
|
durationMs: Number.isFinite(entry.durationMs) ? Math.max(0, Math.round(entry.durationMs)) : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
@@ -103,10 +131,12 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
discordUserId: meta.discordUserId || null,
|
discordUserId: meta.discordUserId || null,
|
||||||
discordUserName: meta.discordUserName || null,
|
discordUserName: meta.discordUserName || null,
|
||||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||||
|
profileImage: normalizeProfileImageUrl(meta.profileImage),
|
||||||
roverCtx: meta.roverCtx || null,
|
roverCtx: meta.roverCtx || null,
|
||||||
text,
|
text,
|
||||||
|
toolCalls,
|
||||||
tts: meta.tts || null,
|
tts: meta.tts || null,
|
||||||
system: Boolean(meta.system),
|
bot: Boolean(meta.bot),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { broadcastMessage, broadcastTyping } = require('./broadcast');
|
|||||||
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
const { playTypingNote, normalizeTtsOptions, maybeSendAccessNotice, maybeSpeak, TYPING_SEND_NOTE } = require('./notifications');
|
||||||
|
|
||||||
function createHandlers({ sendSystemMessage }) {
|
function createHandlers({ sendSystemMessage }) {
|
||||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
function handleIncoming({ text, tts, bot = false, profileImage = null } = {}, socket, cb = () => {}) {
|
||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
void role;
|
void role;
|
||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
@@ -27,6 +27,8 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
roverId,
|
roverId,
|
||||||
roverCtx: buildRoverCtxSnapshot(roverId),
|
roverCtx: buildRoverCtxSnapshot(roverId),
|
||||||
tts: ttsOptions,
|
tts: ttsOptions,
|
||||||
|
bot,
|
||||||
|
profileImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
@@ -45,7 +47,7 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
cb({ success: true });
|
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 }) {
|
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 normalized = normalizeUserText(text);
|
||||||
const clean = normalized.trim();
|
const clean = normalized.trim();
|
||||||
if (!clean || clean.length > 400) throw new Error('Message invalid');
|
if (!clean || clean.length > 400) throw new Error('Message invalid');
|
||||||
@@ -66,6 +68,8 @@ function createHandlers({ sendSystemMessage }) {
|
|||||||
discordUserId,
|
discordUserId,
|
||||||
discordUserName,
|
discordUserName,
|
||||||
discordUserAvatarUrl,
|
discordUserAvatarUrl,
|
||||||
|
bot,
|
||||||
|
profileImage,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('External chat message', { roverId, nickname });
|
logger.info('External chat message', { roverId, nickname });
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
// Chat Service Orchestrator
|
// Chat Service Orchestrator
|
||||||
// Purpose: Composes chat submodules into the public service API and boots socket/event wiring.
|
// 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.
|
// Scope: Keeps external chat contracts stable while delegating logic to focused modules.
|
||||||
const { history } = require('./state');
|
const { history, clearHistory } = require('./state');
|
||||||
const { normalizeUserText } = require('./contentFilters');
|
const { normalizeUserText } = require('./contentFilters');
|
||||||
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
|
const { buildMessage, buildTypingPayload } = require('./contextBuilders');
|
||||||
const { broadcastMessage, getRecentMessages } = require('./broadcast');
|
const { broadcastMessage, getRecentMessages } = require('./broadcast');
|
||||||
const { createHandlers } = require('./handlers');
|
const { createHandlers } = require('./handlers');
|
||||||
const { registerChatSocketHooks } = require('./socketHooks');
|
const { registerChatSocketHooks } = require('./socketHooks');
|
||||||
|
|
||||||
function sendSystemMessage(text) {
|
function sendSystemMessage(text, options = {}) {
|
||||||
const normalized = normalizeUserText(text);
|
const normalized = normalizeUserText(text);
|
||||||
const clean = normalized.trim();
|
const clean = normalized.trim();
|
||||||
if (!clean) return null;
|
const hasToolCalls = Array.isArray(options.toolCalls) && options.toolCalls.length > 0;
|
||||||
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
if (!clean && !hasToolCalls) return null;
|
||||||
|
const safe = clean;
|
||||||
const message = buildMessage(null, safe, {
|
const message = buildMessage(null, safe, {
|
||||||
nickname: 'The Overseer',
|
nickname: String(options.nickname || 'The Overseer'),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
fromDiscord: false,
|
fromDiscord: false,
|
||||||
system: true,
|
bot: options.bot !== false,
|
||||||
|
profileImage: options.profileImage || null,
|
||||||
|
toolCalls: hasToolCalls ? options.toolCalls : null,
|
||||||
});
|
});
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
return message;
|
return message;
|
||||||
@@ -34,4 +37,5 @@ module.exports = {
|
|||||||
buildTypingPayload,
|
buildTypingPayload,
|
||||||
sendSystemMessage,
|
sendSystemMessage,
|
||||||
getRecentMessages,
|
getRecentMessages,
|
||||||
|
clearHistory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function buildAccessNoticeText(mode, reasonText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldSendAccessNotice(message) {
|
function shouldSendAccessNotice(message) {
|
||||||
if (!message?.text || message.system) return false;
|
if (!message?.text || message.bot) return false;
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function pushHistory(message) {
|
|||||||
function getRecentMessages(limit = 20, options = {}) {
|
function getRecentMessages(limit = 20, options = {}) {
|
||||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||||
const includeSystem = options?.includeSystem !== false;
|
const includeSystem = options?.includeSystem !== false;
|
||||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
|
||||||
return source.slice(-safeLimit);
|
return source.slice(-safeLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +40,12 @@ function setLastAccessNoticeAt(ts) {
|
|||||||
lastAccessNoticeAt = ts;
|
lastAccessNoticeAt = ts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
history.length = 0;
|
||||||
|
lastMessageBySocket.clear();
|
||||||
|
typingBySocket.clear();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rateBuckets,
|
rateBuckets,
|
||||||
history,
|
history,
|
||||||
@@ -50,4 +56,5 @@ module.exports = {
|
|||||||
getRecentMessages,
|
getRecentMessages,
|
||||||
getLastAccessNoticeAt,
|
getLastAccessNoticeAt,
|
||||||
setLastAccessNoticeAt,
|
setLastAccessNoticeAt,
|
||||||
|
clearHistory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -250,6 +250,19 @@ function createBusEventHandler(deps) {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'buttonBox.discordPingEveryone': {
|
||||||
|
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
|
||||||
|
sendToChannel(
|
||||||
|
channels.general,
|
||||||
|
`@everyone ${message}`.trim(),
|
||||||
|
{
|
||||||
|
embeds: [buildEmbed({ title: 'Button Box', description: message, color: 0xff3b30, includeSiteUrl: false })],
|
||||||
|
},
|
||||||
|
{ parse: ['everyone'] },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
|
// Scope: Handles inbound Discord messages plus outbound webhook and typing relay.
|
||||||
const { WebhookClient } = require('discord.js');
|
const { WebhookClient } = require('discord.js');
|
||||||
|
|
||||||
|
function summarizeToolCall(entry = {}) {
|
||||||
|
const tool = String(entry?.tool || 'unknown');
|
||||||
|
const status = String(entry?.status || 'unknown').toLowerCase();
|
||||||
|
const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {});
|
||||||
|
if (status === 'ok') return `ok ${tool} args=${argsText}`;
|
||||||
|
if (status === 'blocked') return `blocked ${tool} args=${argsText} - ${String(entry?.error || 'blocked')}`;
|
||||||
|
if (status === 'error') return `error ${tool} args=${argsText} - ${String(entry?.error || 'failed')}`;
|
||||||
|
return `unknown ${tool} args=${argsText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolCallsCodeBlock(toolCalls = []) {
|
||||||
|
const rows = (toolCalls || []).map((entry) => summarizeToolCall(entry));
|
||||||
|
if (!rows.length) return '';
|
||||||
|
return `\`\`\`txt\nTool calls:\n${rows.join('\n')}\n\`\`\``;
|
||||||
|
}
|
||||||
|
|
||||||
function createChatBridgeHandlers(deps) {
|
function createChatBridgeHandlers(deps) {
|
||||||
const {
|
const {
|
||||||
logger,
|
logger,
|
||||||
@@ -48,9 +64,17 @@ function createChatBridgeHandlers(deps) {
|
|||||||
const guildConfigs = listGuildConfigs();
|
const guildConfigs = listGuildConfigs();
|
||||||
if (!guildConfigs.length) return;
|
if (!guildConfigs.length) return;
|
||||||
|
|
||||||
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
|
const baseText = String(payload.text || '');
|
||||||
|
const toolCalls = Array.isArray(payload.toolCalls) ? payload.toolCalls : [];
|
||||||
|
const toolsBlock = formatToolCallsCodeBlock(toolCalls);
|
||||||
|
let text = baseText;
|
||||||
|
if (toolsBlock) {
|
||||||
|
text = text ? `${text}\n\n${toolsBlock}` : `${toolsBlock}`;
|
||||||
|
}
|
||||||
|
if (text.length > 1900) text = `${text.slice(0, 1897)}...`;
|
||||||
|
if (!text.trim()) return;
|
||||||
const username = formatWebhookUsername(payload);
|
const username = formatWebhookUsername(payload);
|
||||||
const avatarURL = payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null;
|
const avatarURL = payload.profileImage || (payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null);
|
||||||
const typingId = getTypingId(payload);
|
const typingId = getTypingId(payload);
|
||||||
|
|
||||||
guildConfigs.forEach((entry) => {
|
guildConfigs.forEach((entry) => {
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ function formatDuration(ms) {
|
|||||||
|
|
||||||
function formatWebhookUsername(payload) {
|
function formatWebhookUsername(payload) {
|
||||||
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
||||||
|
const botTag = payload.bot ? ' [BOT]' : '';
|
||||||
|
const spectatorTag = payload.role === 'spectator' && !payload.bot ? ' [SPECTATOR]' : '';
|
||||||
|
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
||||||
if (payload.fromDiscord) {
|
if (payload.fromDiscord) {
|
||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||||
return `${name}${origin}${adminTag}`;
|
|
||||||
}
|
}
|
||||||
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
|
const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
|
||||||
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
|
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||||
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
|
|
||||||
return suffix ? `${name} · ${suffix}` : name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypingId(payload = {}) {
|
function getTypingId(payload = {}) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function buildEmbedCopy(state, camera) {
|
|||||||
lockdown: 'locked',
|
lockdown: 'locked',
|
||||||
}[mode] || mode;
|
}[mode] || mode;
|
||||||
|
|
||||||
let title = 'Multi Roomba Rover';
|
let title = 'Roomba Rover';
|
||||||
if (mode === 'lockdown') {
|
if (mode === 'lockdown') {
|
||||||
title = 'Private mode is on';
|
title = 'Private mode is on';
|
||||||
} else if (roversOnline === 0) {
|
} else if (roversOnline === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('liftService');
|
const logger = require('../../globals/logger').child('liftService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
|
const { getMode, MODES } = require('../modeManager');
|
||||||
|
const { isLockdownAdmin } = require('../roleService');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -178,6 +180,9 @@ homeAssistantEvents.on('status', emitUpdate);
|
|||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('lift:up', async (_, cb = () => {}) => {
|
socket.on('lift:up', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
|
throw new Error('Server in lockdown');
|
||||||
|
}
|
||||||
if (!isVerified(socket)) throw new Error('VIP verification required');
|
if (!isVerified(socket)) throw new Error('VIP verification required');
|
||||||
const resp = await moveUp(socket.id || 'socket');
|
const resp = await moveUp(socket.id || 'socket');
|
||||||
cb({ success: true, ...resp });
|
cb({ success: true, ...resp });
|
||||||
@@ -188,6 +193,9 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('lift:down', async (_, cb = () => {}) => {
|
socket.on('lift:down', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
|
throw new Error('Server in lockdown');
|
||||||
|
}
|
||||||
if (!isVerified(socket)) throw new Error('VIP verification required');
|
if (!isVerified(socket)) throw new Error('VIP verification required');
|
||||||
const resp = await moveDown(socket.id || 'socket');
|
const resp = await moveDown(socket.id || 'socket');
|
||||||
cb({ success: true, ...resp });
|
cb({ success: true, ...resp });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('llmCommentary');
|
const logger = require('../../globals/logger').child('llmCommentary');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getActiveDrivers } = require('../turnService');
|
const { getActiveDrivers } = require('../turnService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
@@ -258,17 +259,48 @@ const runner = createRunner({
|
|||||||
updateStatus,
|
updateStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerHooks({
|
const canRunFromConfig = enabled && model && ollamaUrl;
|
||||||
io,
|
|
||||||
roleEvents,
|
|
||||||
roverManager,
|
|
||||||
emitStatusToSocket,
|
|
||||||
isAdminSocket,
|
|
||||||
clearRuntimeHistory: runner.clearRuntimeHistory,
|
|
||||||
getAdminState: () => buildAdminState(status, runtime.runHistory),
|
|
||||||
onDriverActivity: runner.wakeForDriverActivity,
|
|
||||||
onSensorEvent: snapshotEngine.onSensorEvent,
|
|
||||||
onRoverRemoved: snapshotEngine.removeRover,
|
|
||||||
});
|
|
||||||
|
|
||||||
runner.start();
|
if (canRunFromConfig) {
|
||||||
|
registerHooks({
|
||||||
|
io,
|
||||||
|
roleEvents,
|
||||||
|
roverManager,
|
||||||
|
emitStatusToSocket,
|
||||||
|
isAdminSocket,
|
||||||
|
clearRuntimeHistory: runner.clearRuntimeHistory,
|
||||||
|
getAdminState: () => buildAdminState(status, runtime.runHistory),
|
||||||
|
onDriverActivity: runner.wakeForDriverActivity,
|
||||||
|
onSensorEvent: snapshotEngine.onSensorEvent,
|
||||||
|
onRoverRemoved: snapshotEngine.removeRover,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
runner.stop('paused during lockdown');
|
||||||
|
logger.info('LLM commentary paused due to lockdown mode');
|
||||||
|
} else {
|
||||||
|
runner.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
modeEvents.on('change', (nextMode) => {
|
||||||
|
if (nextMode === MODES.LOCKDOWN) {
|
||||||
|
runner.stop('paused during lockdown');
|
||||||
|
logger.info('LLM commentary paused due to lockdown mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runner.start();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const disabledReason = !enabled
|
||||||
|
? 'llmCommentary.enabled is false'
|
||||||
|
: 'model or ollama server missing';
|
||||||
|
updatePhase('disabled', {
|
||||||
|
running: false,
|
||||||
|
inFlight: false,
|
||||||
|
currentRunId: null,
|
||||||
|
lastOutcome: 'disabled',
|
||||||
|
lastReason: disabledReason,
|
||||||
|
});
|
||||||
|
logger.info('LLM commentary service not started', { reason: disabledReason });
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ function createRunner(deps) {
|
|||||||
scheduleNextTick(runTick, 0);
|
scheduleNextTick(runTick, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stop(reason = 'stopped') {
|
||||||
|
if (runtime.timer) {
|
||||||
|
clearTimeout(runtime.timer);
|
||||||
|
runtime.timer = null;
|
||||||
|
}
|
||||||
|
updatePhase('paused', {
|
||||||
|
running: false,
|
||||||
|
inFlight: false,
|
||||||
|
currentRunId: null,
|
||||||
|
nextRunAt: null,
|
||||||
|
lastOutcome: 'paused',
|
||||||
|
lastReason: reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function clearRuntimeHistory() {
|
function clearRuntimeHistory() {
|
||||||
runtime.contextResetAt = Date.now();
|
runtime.contextResetAt = Date.now();
|
||||||
runtime.clearCount += 1;
|
runtime.clearCount += 1;
|
||||||
@@ -208,7 +223,7 @@ function createRunner(deps) {
|
|||||||
updatePhase('decision_post', { lastGeneratedText: text });
|
updatePhase('decision_post', { lastGeneratedText: text });
|
||||||
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
|
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
|
||||||
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
|
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
|
||||||
.filter((entry) => entry?.system)
|
.filter((entry) => entry?.bot)
|
||||||
.slice(-Math.max(3, maxBotMessages));
|
.slice(-Math.max(3, maxBotMessages));
|
||||||
const duplicateKey = normalizeDuplicateKey(text);
|
const duplicateKey = normalizeDuplicateKey(text);
|
||||||
const duplicate = recentBotMessages.some(
|
const duplicate = recentBotMessages.some(
|
||||||
@@ -331,6 +346,7 @@ function createRunner(deps) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
start,
|
start,
|
||||||
|
stop,
|
||||||
runTick,
|
runTick,
|
||||||
clearRuntimeHistory,
|
clearRuntimeHistory,
|
||||||
wakeForDriverActivity: () => wakeForDriverActivity(runTick),
|
wakeForDriverActivity: () => wakeForDriverActivity(runTick),
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ function createSnapshotEngine(deps) {
|
|||||||
return roverManager.canReplayRoverId(roverId);
|
return roverManager.canReplayRoverId(roverId);
|
||||||
});
|
});
|
||||||
const chatRecent = allRecentMessages
|
const chatRecent = allRecentMessages
|
||||||
.filter((entry) => !entry?.system)
|
.filter((entry) => !entry?.bot)
|
||||||
.slice(-MAX_CHAT_MESSAGES)
|
.slice(-MAX_CHAT_MESSAGES)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||||
@@ -380,7 +380,7 @@ function createSnapshotEngine(deps) {
|
|||||||
|
|
||||||
const botRecentWindow = allRecentMessages
|
const botRecentWindow = allRecentMessages
|
||||||
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
||||||
.filter((entry) => entry?.system);
|
.filter((entry) => entry?.bot);
|
||||||
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
|
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
|
||||||
const botRecent30m = botRecentWindow.filter(
|
const botRecent30m = botRecentWindow.filter(
|
||||||
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
|
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
|
||||||
@@ -408,7 +408,7 @@ function createSnapshotEngine(deps) {
|
|||||||
summary: entry.summary || '',
|
summary: entry.summary || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (entry?.system) {
|
if (entry?.bot) {
|
||||||
return {
|
return {
|
||||||
type: 'bot',
|
type: 'bot',
|
||||||
nickname: entry.nickname || 'Rover Bot',
|
nickname: entry.nickname || 'Rover Bot',
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('neatoService');
|
const logger = require('../../globals/logger').child('neatoService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
const { createLidarRuntime } = require('./lidarRuntime');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
|
const { isLockdownAdmin } = require('../roleService');
|
||||||
const {
|
const {
|
||||||
homeAssistantEvents,
|
homeAssistantEvents,
|
||||||
getRawEntitySnapshot,
|
getRawEntitySnapshot,
|
||||||
@@ -28,11 +29,6 @@ function normalizeDeviceName(value) {
|
|||||||
|
|
||||||
const device = normalizeDeviceName(neatoConfig.device);
|
const device = normalizeDeviceName(neatoConfig.device);
|
||||||
const RESUME_DELAY_MS = 3000;
|
const RESUME_DELAY_MS = 3000;
|
||||||
const brainslugHost = String(neatoConfig.brainslugHost || '').trim();
|
|
||||||
const brainslugPort = Number(neatoConfig.brainslugPort) || 6053;
|
|
||||||
const brainslugKey = String(neatoConfig.brainslugKey || '').trim();
|
|
||||||
const brainslugLogFile = String(neatoConfig.brainslugLogFile || '').trim();
|
|
||||||
let lidarRuntime = null;
|
|
||||||
|
|
||||||
function entityId(domain, suffix) {
|
function entityId(domain, suffix) {
|
||||||
if (!device) return '';
|
if (!device) return '';
|
||||||
@@ -165,7 +161,6 @@ function buildState() {
|
|||||||
enabled,
|
enabled,
|
||||||
configured,
|
configured,
|
||||||
connected,
|
connected,
|
||||||
lidarConnected: lidarRuntime?.getState?.().connected || false,
|
|
||||||
device,
|
device,
|
||||||
entityPrefix: device ? `${device}_` : '',
|
entityPrefix: device ? `${device}_` : '',
|
||||||
controls,
|
controls,
|
||||||
@@ -226,14 +221,6 @@ async function pressButton(entityIdValue, actionLabel) {
|
|||||||
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
logger.info('Issued Neato action', { action: actionLabel, entityId: entityIdValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestLidarScan() {
|
|
||||||
assertConfiguredAndConnected();
|
|
||||||
if (!device) {
|
|
||||||
throw new Error('Neato not configured');
|
|
||||||
}
|
|
||||||
await callHomeAssistantService('esphome', `${device}_send_cmd`, { command: 'GetLDSScan' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startCleaning() {
|
async function startCleaning() {
|
||||||
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
await pressButton(ENTITY_IDS.buttons.start, 'start');
|
||||||
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, RESUME_DELAY_MS));
|
||||||
@@ -268,41 +255,16 @@ function hasVerifiedSockets() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function broadcastLidarScan(payload) {
|
|
||||||
for (const socket of io.sockets.sockets.values()) {
|
|
||||||
if (!isVerified(socket)) continue;
|
|
||||||
socket.emit('neato:lidar', payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lidarRuntime =
|
|
||||||
brainslugHost && brainslugKey
|
|
||||||
? createLidarRuntime({
|
|
||||||
logger,
|
|
||||||
host: brainslugHost,
|
|
||||||
port: brainslugPort,
|
|
||||||
key: brainslugKey,
|
|
||||||
logFile: brainslugLogFile,
|
|
||||||
shouldPoll: () => Boolean(homeAssistantEnabled && isHomeAssistantConnected() && hasVerifiedSockets()),
|
|
||||||
requestScan: requestLidarScan,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (lidarRuntime) {
|
|
||||||
lidarRuntime.on('scan', (payload) => {
|
|
||||||
broadcastLidarScan(payload);
|
|
||||||
});
|
|
||||||
lidarRuntime.on('status', () => {
|
|
||||||
emitUpdate();
|
|
||||||
});
|
|
||||||
lidarRuntime.start();
|
|
||||||
} else if (device) {
|
|
||||||
logger.info('Neato lidar stream disabled; brainslugHost/brainslugKey missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
|
function assertLockdownAccess() {
|
||||||
|
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
|
||||||
|
throw new Error('Server in lockdown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('neato:start', async (_, cb = () => {}) => {
|
socket.on('neato:start', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
assertLockdownAccess();
|
||||||
if (!isVerified(socket)) {
|
if (!isVerified(socket)) {
|
||||||
throw new Error('VIP verification required');
|
throw new Error('VIP verification required');
|
||||||
}
|
}
|
||||||
@@ -315,6 +277,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:sendHome', async (_, cb = () => {}) => {
|
socket.on('neato:sendHome', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
assertLockdownAccess();
|
||||||
if (!isVerified(socket)) {
|
if (!isVerified(socket)) {
|
||||||
throw new Error('VIP verification required');
|
throw new Error('VIP verification required');
|
||||||
}
|
}
|
||||||
@@ -327,6 +290,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:locate', async (_, cb = () => {}) => {
|
socket.on('neato:locate', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
assertLockdownAccess();
|
||||||
if (!isVerified(socket)) {
|
if (!isVerified(socket)) {
|
||||||
throw new Error('VIP verification required');
|
throw new Error('VIP verification required');
|
||||||
}
|
}
|
||||||
@@ -339,6 +303,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
|
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
assertLockdownAccess();
|
||||||
if (!isVerified(socket)) {
|
if (!isVerified(socket)) {
|
||||||
throw new Error('VIP verification required');
|
throw new Error('VIP verification required');
|
||||||
}
|
}
|
||||||
@@ -351,6 +316,7 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
|
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
|
||||||
try {
|
try {
|
||||||
|
assertLockdownAccess();
|
||||||
if (!isVerified(socket)) {
|
if (!isVerified(socket)) {
|
||||||
throw new Error('VIP verification required');
|
throw new Error('VIP verification required');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
const { spawn, spawnSync } = require('child_process');
|
|
||||||
const EventEmitter = require('events');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const SCAN_TIMEOUT_MS = 8000;
|
|
||||||
const RECONNECT_DELAY_MS = 5000;
|
|
||||||
const IDLE_RETRY_MS = 1000;
|
|
||||||
|
|
||||||
function sanitizeLogText(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.replace(/\u0000/g, '')
|
|
||||||
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePayloadFromLine(line) {
|
|
||||||
const raw = sanitizeLogText(line).trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
const quotedMatch = raw.match(/<<< "(.*)"$/);
|
|
||||||
if (!quotedMatch) return null;
|
|
||||||
const payload = quotedMatch[1];
|
|
||||||
if (!payload) return null;
|
|
||||||
if (
|
|
||||||
payload.includes('\\r\\n') ||
|
|
||||||
payload.includes('\\n') ||
|
|
||||||
payload.includes('\\r') ||
|
|
||||||
payload.includes('\r') ||
|
|
||||||
payload.includes('\n')
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') return payload;
|
|
||||||
if (/^ROTATION_SPEED,[0-9.]+$/.test(payload)) return payload;
|
|
||||||
if (/^\d+,\d+,\d+,[0-9A-Fa-f]+$/.test(payload)) return payload;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePointPayload(payload) {
|
|
||||||
const match = String(payload || '').match(/^(\d+),(\d+),(\d+),([0-9A-Fa-f]+)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const angleDeg = Number(match[1]);
|
|
||||||
const distanceMm = Number(match[2]);
|
|
||||||
const intensity = Number(match[3]);
|
|
||||||
const errorCodeHex = String(match[4]).toUpperCase();
|
|
||||||
return {
|
|
||||||
angleDeg,
|
|
||||||
distanceMm,
|
|
||||||
intensity,
|
|
||||||
errorCodeHex,
|
|
||||||
valid: errorCodeHex === '0' && distanceMm > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRotationSpeed(payload) {
|
|
||||||
const match = String(payload || '').match(/^ROTATION_SPEED,([0-9.]+)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
return Number(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, requestScan }) {
|
|
||||||
const events = new EventEmitter();
|
|
||||||
const state = {
|
|
||||||
connected: false,
|
|
||||||
process: null,
|
|
||||||
reconnectTimer: null,
|
|
||||||
pollSoonTimer: null,
|
|
||||||
requestTimeoutTimer: null,
|
|
||||||
stdoutBuffer: '',
|
|
||||||
stderrBuffer: '',
|
|
||||||
currentScan: null,
|
|
||||||
requestInFlight: false,
|
|
||||||
requestStartedAt: 0,
|
|
||||||
logStream: null,
|
|
||||||
stats: {
|
|
||||||
headersSeen: 0,
|
|
||||||
rotationsSeen: 0,
|
|
||||||
scansOk: 0,
|
|
||||||
scansTimedOut: 0,
|
|
||||||
scansRestarted: 0,
|
|
||||||
rotationsWithoutScan: 0,
|
|
||||||
parseablePayloads: 0,
|
|
||||||
pointPayloads: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function emitStatus() {
|
|
||||||
events.emit('status', { connected: state.connected });
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearReconnectTimer() {
|
|
||||||
if (!state.reconnectTimer) return;
|
|
||||||
clearTimeout(state.reconnectTimer);
|
|
||||||
state.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPollSoonTimer() {
|
|
||||||
if (!state.pollSoonTimer) return;
|
|
||||||
clearTimeout(state.pollSoonTimer);
|
|
||||||
state.pollSoonTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRequestTimeoutTimer() {
|
|
||||||
if (!state.requestTimeoutTimer) return;
|
|
||||||
clearTimeout(state.requestTimeoutTimer);
|
|
||||||
state.requestTimeoutTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerPollSoon() {
|
|
||||||
if (state.pollSoonTimer) return;
|
|
||||||
state.pollSoonTimer = setTimeout(() => {
|
|
||||||
state.pollSoonTimer = null;
|
|
||||||
tickPoll();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedulePollRetry(delayMs = IDLE_RETRY_MS) {
|
|
||||||
if (state.pollSoonTimer) return;
|
|
||||||
state.pollSoonTimer = setTimeout(() => {
|
|
||||||
state.pollSoonTimer = null;
|
|
||||||
tickPoll();
|
|
||||||
}, delayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (state.reconnectTimer) return;
|
|
||||||
state.reconnectTimer = setTimeout(() => {
|
|
||||||
state.reconnectTimer = null;
|
|
||||||
startLogStream();
|
|
||||||
}, RECONNECT_DELAY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetScanState() {
|
|
||||||
clearRequestTimeoutTimer();
|
|
||||||
state.currentScan = null;
|
|
||||||
state.requestInFlight = false;
|
|
||||||
state.requestStartedAt = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPoints(scan) {
|
|
||||||
return Array.from(scan?.points?.values?.() || []).sort((a, b) => a.angleDeg - b.angleDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitFrame({ status, reason, scan = state.currentScan }) {
|
|
||||||
const points = buildPoints(scan);
|
|
||||||
const payload = {
|
|
||||||
points,
|
|
||||||
rotationSpeed: scan?.rotationSpeed ?? null,
|
|
||||||
status,
|
|
||||||
debug: {
|
|
||||||
reason,
|
|
||||||
pointsReceived: points.length,
|
|
||||||
validPoints: points.filter((point) => point?.valid).length,
|
|
||||||
requestInFlight: state.requestInFlight,
|
|
||||||
requestStartedAt: state.requestStartedAt || null,
|
|
||||||
stats: { ...state.stats },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
events.emit('scan', payload);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizeScan(reason = 'rotation_complete') {
|
|
||||||
if (!state.currentScan) {
|
|
||||||
state.stats.rotationsWithoutScan += 1;
|
|
||||||
emitFrame({ status: 'error', reason: 'rotation_without_scan', scan: null });
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.stats.scansOk += 1;
|
|
||||||
const payload = emitFrame({ status: 'ok', reason });
|
|
||||||
logger.info('Neato lidar scan parsed', {
|
|
||||||
points: payload.points.length,
|
|
||||||
rotationSpeed: payload.rotationSpeed,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePayload(payload) {
|
|
||||||
if (!payload) return;
|
|
||||||
state.stats.parseablePayloads += 1;
|
|
||||||
if (payload === 'AngleInDegrees,DistInMM,Intensity,ErrorCodeHEX') {
|
|
||||||
state.stats.headersSeen += 1;
|
|
||||||
if (state.currentScan) {
|
|
||||||
state.stats.scansRestarted += 1;
|
|
||||||
emitFrame({ status: 'error', reason: 'header_restart' });
|
|
||||||
}
|
|
||||||
state.currentScan = {
|
|
||||||
points: new Map(),
|
|
||||||
rotationSpeed: null,
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rotationSpeed = parseRotationSpeed(payload);
|
|
||||||
if (rotationSpeed != null) {
|
|
||||||
state.stats.rotationsSeen += 1;
|
|
||||||
if (state.currentScan) {
|
|
||||||
state.currentScan.rotationSpeed = rotationSpeed;
|
|
||||||
}
|
|
||||||
finalizeScan('rotation_complete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const point = parsePointPayload(payload);
|
|
||||||
if (!point || !state.currentScan) return;
|
|
||||||
state.stats.pointPayloads += 1;
|
|
||||||
state.currentScan.points.set(point.angleDeg, point);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTextChunk(chunk, bufferKey, sourceLabel = '') {
|
|
||||||
const text = String(chunk || '');
|
|
||||||
if (state.logStream) {
|
|
||||||
const prefix = sourceLabel ? `[${sourceLabel}] ` : '';
|
|
||||||
state.logStream.write(
|
|
||||||
text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line, index, parts) => (index === parts.length - 1 && line === '' ? '' : `${prefix}${line}`))
|
|
||||||
.join('\n'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
state[bufferKey] += sanitizeLogText(text);
|
|
||||||
const lines = state[bufferKey].split(/\r?\n/);
|
|
||||||
state[bufferKey] = lines.pop() || '';
|
|
||||||
for (const line of lines) {
|
|
||||||
events.emit('line', {
|
|
||||||
source: sourceLabel || 'unknown',
|
|
||||||
line: sanitizeLogText(line),
|
|
||||||
});
|
|
||||||
const payload = parsePayloadFromLine(line);
|
|
||||||
handlePayload(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureConnected() {
|
|
||||||
if (state.connected) return;
|
|
||||||
state.connected = true;
|
|
||||||
emitStatus();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
|
|
||||||
function teardownProcess() {
|
|
||||||
const proc = state.process;
|
|
||||||
state.process = null;
|
|
||||||
state.connected = false;
|
|
||||||
emitStatus();
|
|
||||||
resetScanState();
|
|
||||||
if (!proc) return;
|
|
||||||
proc.removeAllListeners();
|
|
||||||
proc.stdout?.removeAllListeners();
|
|
||||||
proc.stderr?.removeAllListeners();
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to stop Neato lidar log process', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureLogStream() {
|
|
||||||
if (!logFile || state.logStream) return;
|
|
||||||
try {
|
|
||||||
state.logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
||||||
logger.info('Neato lidar raw log capture enabled', { logFile });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to open Neato lidar raw log file', { logFile, error: err.message });
|
|
||||||
state.logStream = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLogStream() {
|
|
||||||
if (!state.logStream) return;
|
|
||||||
try {
|
|
||||||
state.logStream.end();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to close Neato lidar raw log file', err.message);
|
|
||||||
}
|
|
||||||
state.logStream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLogStream() {
|
|
||||||
if (!host || !key) return;
|
|
||||||
if (state.process) return;
|
|
||||||
ensureLogStream();
|
|
||||||
|
|
||||||
logger.info('Starting Neato lidar log stream', { host, port: Number(port || 6053) });
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
PYTHONUNBUFFERED: '1',
|
|
||||||
NO_COLOR: '1',
|
|
||||||
TERM: 'dumb',
|
|
||||||
};
|
|
||||||
const uvxArgs = [
|
|
||||||
'--from',
|
|
||||||
'aioesphomeapi',
|
|
||||||
'aioesphomeapi-logs',
|
|
||||||
host,
|
|
||||||
'--port',
|
|
||||||
String(port || 6053),
|
|
||||||
'--noise-psk',
|
|
||||||
key,
|
|
||||||
'--no-states',
|
|
||||||
];
|
|
||||||
const hasScript = spawnSync('bash', ['-lc', 'command -v script >/dev/null 2>&1'], { stdio: 'ignore' }).status === 0;
|
|
||||||
const proc = hasScript
|
|
||||||
? spawn('script', ['-qefc', `uvx ${uvxArgs.map((arg) => JSON.stringify(arg)).join(' ')}`, '/dev/null'], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
: spawn('uvx', uvxArgs, {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
logger.info('Neato lidar log launch mode', { mode: hasScript ? 'script' : 'direct' });
|
|
||||||
state.process = proc;
|
|
||||||
state.stdoutBuffer = '';
|
|
||||||
state.stderrBuffer = '';
|
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk) => {
|
|
||||||
ensureConnected();
|
|
||||||
handleTextChunk(chunk, 'stdoutBuffer', 'stdout');
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on('data', (chunk) => {
|
|
||||||
ensureConnected();
|
|
||||||
handleTextChunk(chunk, 'stderrBuffer', 'stderr');
|
|
||||||
const message = String(chunk || '').trim();
|
|
||||||
if (message) logger.warn('Neato lidar log stream stderr', message);
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('close', (code, signal) => {
|
|
||||||
logger.warn('Neato lidar log stream stopped', { code, signal });
|
|
||||||
teardownProcess();
|
|
||||||
scheduleReconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
|
||||||
logger.warn('Neato lidar log stream error', err.message);
|
|
||||||
teardownProcess();
|
|
||||||
scheduleReconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tickPoll() {
|
|
||||||
if (!state.connected) {
|
|
||||||
schedulePollRetry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!shouldPoll?.()) {
|
|
||||||
schedulePollRetry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.requestInFlight) return;
|
|
||||||
state.requestInFlight = true;
|
|
||||||
state.requestStartedAt = Date.now();
|
|
||||||
clearRequestTimeoutTimer();
|
|
||||||
state.requestTimeoutTimer = setTimeout(() => {
|
|
||||||
logger.warn('Neato lidar scan timed out; resetting parser state');
|
|
||||||
state.stats.scansTimedOut += 1;
|
|
||||||
emitFrame({ status: 'error', reason: 'scan_timeout' });
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
}, SCAN_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
await requestScan?.();
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to request Neato lidar scan', err.message);
|
|
||||||
resetScanState();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
startLogStream();
|
|
||||||
triggerPollSoon();
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
clearReconnectTimer();
|
|
||||||
clearPollSoonTimer();
|
|
||||||
clearRequestTimeoutTimer();
|
|
||||||
teardownProcess();
|
|
||||||
closeLogStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getState() {
|
|
||||||
return {
|
|
||||||
connected: state.connected,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
getState,
|
|
||||||
on: (...args) => events.on(...args),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createLidarRuntime,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_control_system.txt');
|
||||||
|
const DEFAULT_NAME = 'The Overseer';
|
||||||
|
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
||||||
|
const DEFAULT_HEARTBEAT_MS = 30000;
|
||||||
|
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
|
||||||
|
const MIN_INTERVAL_MS = 250;
|
||||||
|
const MAX_RUN_HISTORY = 100;
|
||||||
|
const MAX_CHAT_CONTEXT = 12;
|
||||||
|
const MAX_BOT_CONTEXT = 2;
|
||||||
|
|
||||||
|
function normalizeMs(value, fallback) {
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.max(MIN_INTERVAL_MS, Math.floor(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PROMPT_PATH,
|
||||||
|
DEFAULT_NAME,
|
||||||
|
DEFAULT_GATE_INTERVAL_MS,
|
||||||
|
DEFAULT_HEARTBEAT_MS,
|
||||||
|
DEFAULT_POST_CHAT_DELAY_MS,
|
||||||
|
MAX_RUN_HISTORY,
|
||||||
|
MAX_CHAT_CONTEXT,
|
||||||
|
MAX_BOT_CONTEXT,
|
||||||
|
normalizeMs,
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
const { evaluateTools } = require('./tools');
|
||||||
|
|
||||||
|
function normalizeNeatoIssue(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return 'none';
|
||||||
|
if (raw.includes('200')) return 'none';
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) {
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
|
||||||
|
lines.push(`mode: ${mode || 'unknown'}`);
|
||||||
|
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
|
||||||
|
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
|
||||||
|
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
|
||||||
|
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
||||||
|
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
||||||
|
lines.push(
|
||||||
|
`neato: ${neatoState?.connected ? 'connected' : 'offline'} state=${neatoState?.telemetry?.robotState || 'unknown'} error=${neatoError} alert=${neatoAlert}`,
|
||||||
|
);
|
||||||
|
const entities = Array.isArray(homeAssistantState?.entities) ? homeAssistantState.entities : [];
|
||||||
|
if (entities.length) {
|
||||||
|
lines.push('home_assistant_entities:');
|
||||||
|
entities.slice(0, 24).forEach((entity) => {
|
||||||
|
lines.push(`- ${entity.id} (${entity.type || 'entity'}) state=${entity.state || 'unknown'} available=${entity.available ? 'yes' : 'no'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const roverLines = (Array.isArray(roster) ? roster : []).slice(0, 6).map((rover) => {
|
||||||
|
const roverId = rover?.id || 'unknown';
|
||||||
|
const drivers = Array.isArray(rover?.drivers) ? rover.drivers.filter(Boolean) : [];
|
||||||
|
const driver = drivers.length ? drivers.join(',') : 'none';
|
||||||
|
const status = rover?.statusTag || 'unknown';
|
||||||
|
return `- ${roverId} status=${status} drivers=${driver}`;
|
||||||
|
});
|
||||||
|
if (roverLines.length) {
|
||||||
|
lines.push('rovers:');
|
||||||
|
lines.push(...roverLines);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) {
|
||||||
|
return evaluateTools({ mode, homeAssistantState, neatoState, liftState });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversation({ recentMessages, name }) {
|
||||||
|
const messages = [];
|
||||||
|
(recentMessages || []).forEach((entry) => {
|
||||||
|
const text = String(entry?.text || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
const isAssistant = Boolean(entry?.bot);
|
||||||
|
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||||
|
if (isAssistant) {
|
||||||
|
messages.push({ role: 'assistant', content: text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messages.push({ role: 'user', content: `${nickname}: ${text}` });
|
||||||
|
});
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModelMessages({
|
||||||
|
systemPrompt,
|
||||||
|
stateUpdate,
|
||||||
|
memorySummary,
|
||||||
|
recentEvents,
|
||||||
|
conversationMessages,
|
||||||
|
availableTools,
|
||||||
|
blockedTools,
|
||||||
|
}) {
|
||||||
|
const messages = [];
|
||||||
|
messages.push({ role: 'system', content: systemPrompt });
|
||||||
|
messages.push({ role: 'system', content: `ROOM_SNAPSHOT\n${stateUpdate}` });
|
||||||
|
if (memorySummary) {
|
||||||
|
messages.push({ role: 'system', content: `MEMORY_SUMMARY\n${memorySummary}` });
|
||||||
|
}
|
||||||
|
if (recentEvents) {
|
||||||
|
messages.push({ role: 'system', content: `RECENT_EVENTS\n${recentEvents}` });
|
||||||
|
}
|
||||||
|
messages.push({
|
||||||
|
role: 'system',
|
||||||
|
content: `TOOL_CONSTRAINTS\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
|
||||||
|
});
|
||||||
|
(conversationMessages || []).forEach((message) => {
|
||||||
|
if (!message || !message.role || !message.content) return;
|
||||||
|
messages.push(message);
|
||||||
|
});
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
toStateUpdate,
|
||||||
|
buildToolState,
|
||||||
|
buildConversation,
|
||||||
|
buildModelMessages,
|
||||||
|
};
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
const fsp = require('fs/promises');
|
||||||
|
const { Ollama } = require('ollama');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('overseerControl');
|
||||||
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
|
const { verificationEvents } = require('../verificationService');
|
||||||
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const neatoService = require('../neatoService');
|
||||||
|
const liftService = require('../liftService');
|
||||||
|
const buttonBoxService = require('../buttonBoxService');
|
||||||
|
const { getState: getHomeAssistantState, homeAssistantEvents } = homeAssistantService;
|
||||||
|
const { getState: getNeatoState, neatoEvents } = neatoService;
|
||||||
|
const { getState: getLiftState, liftEvents } = liftService;
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
||||||
|
const { subscribe } = require('../eventBus');
|
||||||
|
const {
|
||||||
|
PROMPT_PATH,
|
||||||
|
DEFAULT_NAME,
|
||||||
|
DEFAULT_GATE_INTERVAL_MS,
|
||||||
|
DEFAULT_HEARTBEAT_MS,
|
||||||
|
DEFAULT_POST_CHAT_DELAY_MS,
|
||||||
|
MAX_RUN_HISTORY,
|
||||||
|
MAX_CHAT_CONTEXT,
|
||||||
|
MAX_BOT_CONTEXT,
|
||||||
|
normalizeMs,
|
||||||
|
} = require('./constants');
|
||||||
|
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
|
||||||
|
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder');
|
||||||
|
const { buildOllamaTools, executeToolAction } = require('./tools');
|
||||||
|
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const overseerConfig = config.overseerControl || {};
|
||||||
|
const enabled = Boolean(overseerConfig.enabled);
|
||||||
|
const observeOnly = overseerConfig.observeOnly !== false;
|
||||||
|
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
|
||||||
|
const model = String(overseerConfig.model || '').trim();
|
||||||
|
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||||
|
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||||
|
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
||||||
|
const postChatDelayMs = normalizeMs(Number(overseerConfig.postChatDelayMs), DEFAULT_POST_CHAT_DELAY_MS);
|
||||||
|
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
||||||
|
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||||
|
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||||
|
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
timer: null,
|
||||||
|
inFlight: false,
|
||||||
|
tickCount: 0,
|
||||||
|
lastModelAt: 0,
|
||||||
|
generationCount: 0,
|
||||||
|
generationTotalMs: 0,
|
||||||
|
runHistory: [],
|
||||||
|
liveToolCalls: [],
|
||||||
|
memoryStore: loadMemory(),
|
||||||
|
contextResetAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = {
|
||||||
|
enabled,
|
||||||
|
observeOnly,
|
||||||
|
name,
|
||||||
|
model,
|
||||||
|
ollamaUrl,
|
||||||
|
promptPath: PROMPT_PATH,
|
||||||
|
gateIntervalMs,
|
||||||
|
heartbeatMs,
|
||||||
|
postChatDelayMs,
|
||||||
|
alwaysRunModel,
|
||||||
|
postToolsOnlyMessages,
|
||||||
|
running: false,
|
||||||
|
inFlight: false,
|
||||||
|
phase: 'idle',
|
||||||
|
phaseAt: Date.now(),
|
||||||
|
tickCount: 0,
|
||||||
|
currentRunId: null,
|
||||||
|
nextRunAt: null,
|
||||||
|
lastTickAt: null,
|
||||||
|
lastTriggerReason: null,
|
||||||
|
lastSystemPrompt: null,
|
||||||
|
lastStateUpdate: null,
|
||||||
|
lastTranscript: null,
|
||||||
|
lastAvailableTools: null,
|
||||||
|
lastBlockedTools: null,
|
||||||
|
lastModelMessages: null,
|
||||||
|
lastModelInputAt: null,
|
||||||
|
lastModelOutputAt: null,
|
||||||
|
lastModelRawOutput: null,
|
||||||
|
lastDecision: null,
|
||||||
|
lastChatDraft: null,
|
||||||
|
lastRequestedActions: null,
|
||||||
|
lastActionResults: null,
|
||||||
|
lastLiveToolCalls: null,
|
||||||
|
lastOutcome: null,
|
||||||
|
lastReason: null,
|
||||||
|
voteStatus: null,
|
||||||
|
lastError: null,
|
||||||
|
lastErrorDetails: null,
|
||||||
|
lastFailedAt: null,
|
||||||
|
lastGenerationMs: null,
|
||||||
|
avgGenerationMs: null,
|
||||||
|
generationCount: 0,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildVoteStatus() {
|
||||||
|
const sockets = Array.from(io.sockets.sockets.values());
|
||||||
|
let yesCount = 0;
|
||||||
|
let noCount = 0;
|
||||||
|
const votesByIdentity = new Map();
|
||||||
|
const isEligibleVoter = (socket) => getRole(socket) !== 'spectator';
|
||||||
|
|
||||||
|
sockets.forEach((socket) => {
|
||||||
|
if (!isEligibleVoter(socket)) return;
|
||||||
|
const identityKey = String(socket?.data?.cookieUserId || '').trim() || `socket:${socket.id}`;
|
||||||
|
const pref = typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true;
|
||||||
|
const prev = votesByIdentity.get(identityKey);
|
||||||
|
if (typeof prev === 'boolean') {
|
||||||
|
// If one tab says "no", treat that user as "no" to avoid accidental override by stale tabs.
|
||||||
|
votesByIdentity.set(identityKey, prev && pref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
votesByIdentity.set(identityKey, pref);
|
||||||
|
});
|
||||||
|
|
||||||
|
votesByIdentity.forEach((pref) => {
|
||||||
|
if (pref) yesCount += 1;
|
||||||
|
else noCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const eligibleCount = votesByIdentity.size;
|
||||||
|
const onlineCount = yesCount + noCount;
|
||||||
|
const gatePassed = eligibleCount > 0 && yesCount > noCount;
|
||||||
|
return {
|
||||||
|
yesCount,
|
||||||
|
noCount,
|
||||||
|
onlineCount,
|
||||||
|
eligibleCount,
|
||||||
|
gatePassed,
|
||||||
|
running: Boolean(enabled && gatePassed && getMode() !== MODES.LOCKDOWN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(patch = {}) {
|
||||||
|
status = { ...status, ...patch, updatedAt: Date.now() };
|
||||||
|
const payload = buildAdminState(status, runtime.runHistory);
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (!isAdminRole(getRole(socket))) return;
|
||||||
|
socket.emit('overseer:state', payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushRun(run = {}) {
|
||||||
|
runtime.runHistory = [...runtime.runHistory.slice(-(MAX_RUN_HISTORY - 1)), run];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLiveToolCall(entry = {}) {
|
||||||
|
runtime.liveToolCalls = [...runtime.liveToolCalls.slice(-49), { at: Date.now(), ...entry }];
|
||||||
|
updateStatus({ lastLiveToolCalls: runtime.liveToolCalls });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPrompt() {
|
||||||
|
const raw = await fsp.readFile(PROMPT_PATH, 'utf8');
|
||||||
|
const prompt = String(raw || '').replace(/<NAME>/g, name).trim();
|
||||||
|
if (!prompt) throw new Error(`Prompt file empty: ${PROMPT_PATH}`);
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRosterSummary() {
|
||||||
|
return roverManager
|
||||||
|
.getRoster()
|
||||||
|
.filter((rover) => roverManager.canReplayRoverId(rover?.id))
|
||||||
|
.map((rover) => {
|
||||||
|
const roverId = String(rover?.id || '');
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
const driverSocketIds = record?.drivers ? Array.from(record.drivers) : [];
|
||||||
|
const drivers = driverSocketIds
|
||||||
|
.map((socketId) => {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
const nickname = String(socket?.data?.nickname || socket?.data?.user?.username || '').trim();
|
||||||
|
return nickname || socketId;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const sensors = record?.lastSensor?.decoded || {};
|
||||||
|
const docked = Boolean(record?.docked || sensors?.chargingSources?.homeBase);
|
||||||
|
const oiMode = String(sensors?.oiMode?.label || '').toLowerCase();
|
||||||
|
let statusTag = 'docking';
|
||||||
|
if (docked) {
|
||||||
|
statusTag = 'docked';
|
||||||
|
} else if (oiMode === 'safe' || oiMode === 'full') {
|
||||||
|
statusTag = 'driving';
|
||||||
|
} else if (oiMode === 'passive' || oiMode === 'off' || oiMode === 'unknown' || !oiMode) {
|
||||||
|
statusTag = 'docking';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: roverId || 'unknown',
|
||||||
|
statusTag,
|
||||||
|
drivers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTriggerReason() {
|
||||||
|
if (alwaysRunModel) return 'loop_tick';
|
||||||
|
const recent = getRecentMessages(1, { includeSystem: false });
|
||||||
|
const last = recent[recent.length - 1];
|
||||||
|
if (last && Number(last.ts || 0) < runtime.contextResetAt) return null;
|
||||||
|
if (last && Date.now() - Number(last.ts || 0) < 5000) {
|
||||||
|
const txt = String(last.text || '').toLowerCase();
|
||||||
|
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
|
||||||
|
return 'chat_activity';
|
||||||
|
}
|
||||||
|
if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) return 'heartbeat';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCalls(payload = null) {
|
||||||
|
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
|
||||||
|
return calls
|
||||||
|
.map((call) => {
|
||||||
|
const fn = call?.function || {};
|
||||||
|
const tool = String(fn.name || '').trim();
|
||||||
|
if (!tool) return null;
|
||||||
|
let args = fn.arguments;
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
try {
|
||||||
|
args = JSON.parse(args);
|
||||||
|
} catch {
|
||||||
|
args = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!args || typeof args !== 'object') args = {};
|
||||||
|
return { tool, args };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDecision({ toolCalls, chatText }) {
|
||||||
|
const hasTools = (toolCalls || []).length > 0;
|
||||||
|
const hasChat = Boolean(String(chatText || '').trim());
|
||||||
|
if (hasTools && hasChat) return 'ACTION+CHAT';
|
||||||
|
if (hasTools) return 'ACTION';
|
||||||
|
if (hasChat) return 'CHAT';
|
||||||
|
return 'SKIP';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChatDraft(text) {
|
||||||
|
const next = String(text || '').trim();
|
||||||
|
if (!next) return null;
|
||||||
|
if (next.toUpperCase() === 'SKIP') return null;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeResult(result) {
|
||||||
|
if (!result || typeof result !== 'object') return null;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(result, 'ok')) return { ok: Boolean(result.ok) };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
|
||||||
|
const resultsByTool = new Map();
|
||||||
|
(actionResults || []).forEach((entry) => {
|
||||||
|
if (!entry || entry.kind !== 'tool') return;
|
||||||
|
const key = String(entry.tool || '');
|
||||||
|
if (!key) return;
|
||||||
|
if (!resultsByTool.has(key)) resultsByTool.set(key, []);
|
||||||
|
resultsByTool.get(key).push(entry);
|
||||||
|
});
|
||||||
|
return (requestedActions || []).map((action) => {
|
||||||
|
const tool = String(action?.tool || '').trim() || 'unknown';
|
||||||
|
const bucket = resultsByTool.get(tool) || [];
|
||||||
|
const resultEntry = bucket.length ? bucket.shift() : null;
|
||||||
|
const ok = Boolean(resultEntry?.ok);
|
||||||
|
const errText = resultEntry?.error ? String(resultEntry.error) : '';
|
||||||
|
const status = resultEntry
|
||||||
|
? ok
|
||||||
|
? 'ok'
|
||||||
|
: errText.includes('blocked') || errText.includes('unavailable')
|
||||||
|
? 'blocked'
|
||||||
|
: 'error'
|
||||||
|
: 'started';
|
||||||
|
return {
|
||||||
|
tool,
|
||||||
|
status,
|
||||||
|
args: action?.args && typeof action.args === 'object' ? action.args : {},
|
||||||
|
result: summarizeResult(resultEntry?.result),
|
||||||
|
error: errText || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecentEventsSummary() {
|
||||||
|
const events = (runtime.liveToolCalls || [])
|
||||||
|
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
||||||
|
.slice(-3)
|
||||||
|
.map((entry) => {
|
||||||
|
const tool = String(entry.tool || 'unknown');
|
||||||
|
const phase = String(entry.phase || 'unknown');
|
||||||
|
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
|
||||||
|
return `- tool=${tool} phase=${phase}${err}`;
|
||||||
|
});
|
||||||
|
if (!events.length) return '- none';
|
||||||
|
return events.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDecision(triggerReason) {
|
||||||
|
const runId = runtime.tickCount;
|
||||||
|
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||||
|
|
||||||
|
const mode = getMode();
|
||||||
|
const homeAssistantState = getHomeAssistantState();
|
||||||
|
const neatoState = getNeatoState();
|
||||||
|
const liftState = getLiftState();
|
||||||
|
const roster = buildRosterSummary();
|
||||||
|
|
||||||
|
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
|
||||||
|
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
|
||||||
|
|
||||||
|
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
||||||
|
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!entry?.roverId) return true;
|
||||||
|
return roverManager.canReplayRoverId(entry.roverId);
|
||||||
|
})
|
||||||
|
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||||
|
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
||||||
|
|
||||||
|
const systemPrompt = await readPrompt();
|
||||||
|
const modelMessages = buildModelMessages({
|
||||||
|
systemPrompt,
|
||||||
|
stateUpdate,
|
||||||
|
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||||
|
recentEvents: buildRecentEventsSummary(),
|
||||||
|
conversationMessages,
|
||||||
|
availableTools: toolState.available,
|
||||||
|
blockedTools: toolState.blocked,
|
||||||
|
});
|
||||||
|
const ollamaTools = buildOllamaTools(toolState.availableIds);
|
||||||
|
|
||||||
|
updateStatus({
|
||||||
|
phase: 'awaiting_model',
|
||||||
|
lastSystemPrompt: systemPrompt,
|
||||||
|
lastStateUpdate: stateUpdate,
|
||||||
|
lastTranscript: conversationMessages,
|
||||||
|
lastAvailableTools: toolState.available,
|
||||||
|
lastBlockedTools: toolState.blocked,
|
||||||
|
lastModelMessages: modelMessages,
|
||||||
|
lastModelInputAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const generationStart = Date.now();
|
||||||
|
let payload = null;
|
||||||
|
if (ollamaClient && model) {
|
||||||
|
payload = await ollamaClient.chat({
|
||||||
|
model,
|
||||||
|
stream: false,
|
||||||
|
keep_alive: -1,
|
||||||
|
options: { temperature: 0.25, top_p: 0.9 },
|
||||||
|
messages: modelMessages,
|
||||||
|
tools: ollamaTools,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOutput = String(payload?.message?.content || '');
|
||||||
|
const toolCalls = normalizeToolCalls(payload);
|
||||||
|
const chatDraft = normalizeChatDraft(rawOutput);
|
||||||
|
const decision = inferDecision({ toolCalls, chatText: chatDraft });
|
||||||
|
|
||||||
|
const generationMs = Math.max(0, Date.now() - generationStart);
|
||||||
|
runtime.generationCount += 1;
|
||||||
|
runtime.generationTotalMs += generationMs;
|
||||||
|
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
|
||||||
|
runtime.lastModelAt = Date.now();
|
||||||
|
|
||||||
|
const actionResults = [];
|
||||||
|
const requestedActions = toolCalls;
|
||||||
|
let postedChat = false;
|
||||||
|
let outcome = observeOnly ? 'observed' : 'executed';
|
||||||
|
const reason = observeOnly ? 'observe-only mode' : null;
|
||||||
|
|
||||||
|
if (!observeOnly) {
|
||||||
|
if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
|
||||||
|
for (const action of requestedActions) {
|
||||||
|
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
|
||||||
|
if (!toolState.availableIds.includes(action.tool)) {
|
||||||
|
pushLiveToolCall({ phase: 'blocked', tool: action.tool, error: 'tool unavailable or blocked' });
|
||||||
|
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: 'tool unavailable or blocked' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await executeToolAction(action.tool, action.args, {
|
||||||
|
sendSystemMessage,
|
||||||
|
name,
|
||||||
|
memoryStore: runtime.memoryStore,
|
||||||
|
neatoService,
|
||||||
|
liftService,
|
||||||
|
homeAssistantService,
|
||||||
|
buttonBoxService,
|
||||||
|
actor: 'overseerControl',
|
||||||
|
});
|
||||||
|
if (result?.memory && typeof result.memory === 'object') {
|
||||||
|
runtime.memoryStore = saveMemory(result.memory);
|
||||||
|
}
|
||||||
|
pushLiveToolCall({ phase: 'ok', tool: action.tool, result });
|
||||||
|
actionResults.push({ kind: 'tool', tool: action.tool, ok: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
pushLiveToolCall({ phase: 'error', tool: action.tool, error: err.message });
|
||||||
|
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCallFeed = buildToolCallFeedEntries(requestedActions, actionResults);
|
||||||
|
if (toolCallFeed.length > 0) {
|
||||||
|
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||||
|
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||||
|
postedChat = true;
|
||||||
|
} else if (postToolsOnlyMessages) {
|
||||||
|
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
|
||||||
|
}
|
||||||
|
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
|
||||||
|
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||||
|
postedChat = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus({
|
||||||
|
phase: 'decision_recorded',
|
||||||
|
lastModelOutputAt: Date.now(),
|
||||||
|
lastModelRawOutput: rawOutput,
|
||||||
|
lastDecision: decision,
|
||||||
|
lastChatDraft: chatDraft,
|
||||||
|
lastRequestedActions: requestedActions,
|
||||||
|
lastActionResults: actionResults,
|
||||||
|
lastOutcome: outcome,
|
||||||
|
lastReason: reason,
|
||||||
|
lastGenerationMs: generationMs,
|
||||||
|
avgGenerationMs,
|
||||||
|
generationCount: runtime.generationCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pushRun({
|
||||||
|
runId,
|
||||||
|
at: Date.now(),
|
||||||
|
triggerReason,
|
||||||
|
decision,
|
||||||
|
chatDraft,
|
||||||
|
requestedActions,
|
||||||
|
actionResults,
|
||||||
|
outcome,
|
||||||
|
observeOnly,
|
||||||
|
generationMs,
|
||||||
|
blockedTools: toolState.blocked,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { postedChat };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
runtime.tickCount += 1;
|
||||||
|
runtime.inFlight = true;
|
||||||
|
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
||||||
|
|
||||||
|
let nextDelayMs = gateIntervalMs;
|
||||||
|
try {
|
||||||
|
const triggerReason = computeTriggerReason();
|
||||||
|
if (!triggerReason) {
|
||||||
|
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
||||||
|
} else {
|
||||||
|
const runResult = await runDecision(triggerReason);
|
||||||
|
if (runResult?.postedChat) {
|
||||||
|
nextDelayMs = postChatDelayMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const failure = buildFailureInfo(err);
|
||||||
|
updateStatus({
|
||||||
|
phase: 'failed',
|
||||||
|
lastError: failure.message,
|
||||||
|
lastErrorDetails: failure.details,
|
||||||
|
lastFailedAt: Date.now(),
|
||||||
|
lastOutcome: 'failed',
|
||||||
|
lastReason: 'exception',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
runtime.inFlight = false;
|
||||||
|
if (status.running) {
|
||||||
|
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
|
||||||
|
runtime.timer = setTimeout(tick, nextDelayMs);
|
||||||
|
} else {
|
||||||
|
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitStateToSocket(socket) {
|
||||||
|
if (!socket || !isAdminRole(getRole(socket))) return;
|
||||||
|
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory(reason = 'admin requested clear history', options = {}) {
|
||||||
|
const resetPersistentMemory = options?.resetPersistentMemory !== false;
|
||||||
|
const sendConfirmation = options?.sendConfirmation === true;
|
||||||
|
runtime.contextResetAt = Date.now();
|
||||||
|
runtime.runHistory = [];
|
||||||
|
runtime.liveToolCalls = [];
|
||||||
|
runtime.generationCount = 0;
|
||||||
|
runtime.generationTotalMs = 0;
|
||||||
|
runtime.lastModelAt = 0;
|
||||||
|
if (resetPersistentMemory) {
|
||||||
|
runtime.memoryStore = saveMemory(createDefaultMemory());
|
||||||
|
}
|
||||||
|
updateStatus({
|
||||||
|
phase: 'idle',
|
||||||
|
currentRunId: null,
|
||||||
|
lastSystemPrompt: null,
|
||||||
|
lastStateUpdate: null,
|
||||||
|
lastTranscript: null,
|
||||||
|
lastAvailableTools: null,
|
||||||
|
lastBlockedTools: null,
|
||||||
|
lastModelMessages: null,
|
||||||
|
lastModelInputAt: null,
|
||||||
|
lastModelOutputAt: null,
|
||||||
|
lastModelRawOutput: null,
|
||||||
|
lastDecision: null,
|
||||||
|
lastChatDraft: null,
|
||||||
|
lastRequestedActions: null,
|
||||||
|
lastActionResults: null,
|
||||||
|
lastLiveToolCalls: [],
|
||||||
|
lastOutcome: 'cleared',
|
||||||
|
lastReason: reason,
|
||||||
|
});
|
||||||
|
if (sendConfirmation) {
|
||||||
|
sendSystemMessage('Context cleared.', { nickname: name, bot: true, profileImage: profileImageUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScheduler(reason = 'paused') {
|
||||||
|
if (runtime.timer) {
|
||||||
|
clearTimeout(runtime.timer);
|
||||||
|
runtime.timer = null;
|
||||||
|
}
|
||||||
|
updateStatus({
|
||||||
|
running: false,
|
||||||
|
inFlight: false,
|
||||||
|
currentRunId: null,
|
||||||
|
nextRunAt: null,
|
||||||
|
phase: 'paused',
|
||||||
|
lastOutcome: 'paused',
|
||||||
|
lastReason: reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScheduler(reason = null) {
|
||||||
|
if (runtime.timer) return;
|
||||||
|
updateStatus({ running: true, phase: 'idle', lastReason: reason });
|
||||||
|
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateSchedulerGate(reason = 'gate reevaluated') {
|
||||||
|
const voteStatus = buildVoteStatus();
|
||||||
|
const runningAllowed = Boolean(enabled && voteStatus.gatePassed && getMode() !== MODES.LOCKDOWN);
|
||||||
|
updateStatus({ voteStatus });
|
||||||
|
if (!enabled) {
|
||||||
|
stopScheduler('overseerControl.enabled is false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
stopScheduler('paused during lockdown');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!voteStatus.gatePassed) {
|
||||||
|
stopScheduler('paused by user vote');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startScheduler(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
emitStateToSocket(socket);
|
||||||
|
evaluateSchedulerGate('online vote update');
|
||||||
|
socket.on('disconnect', () => evaluateSchedulerGate('online vote update'));
|
||||||
|
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
|
||||||
|
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
|
||||||
|
const action = controls?.action || null;
|
||||||
|
if (action === 'clearHistory') {
|
||||||
|
clearHistory();
|
||||||
|
return cb({ success: true, state: buildAdminState(status, runtime.runHistory) });
|
||||||
|
}
|
||||||
|
return cb({ error: 'Unknown overseer control action' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roleEvents.on('change', ({ socket }) => {
|
||||||
|
emitStateToSocket(socket);
|
||||||
|
evaluateSchedulerGate('online vote update');
|
||||||
|
});
|
||||||
|
subscribe('chat:message', ({ payload } = {}) => {
|
||||||
|
const text = String(payload?.text || '').trim();
|
||||||
|
if (text !== 'CLEAR') return;
|
||||||
|
if (payload?.bot) return;
|
||||||
|
clearHistory('chat CLEAR command', { resetPersistentMemory: false, sendConfirmation: true });
|
||||||
|
});
|
||||||
|
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
|
||||||
|
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||||
|
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||||
|
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
|
||||||
|
roverManager.managerEvents.on('rover', () => updateStatus({ phase: status.phase }));
|
||||||
|
modeEvents.on('change', (mode) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (mode === MODES.LOCKDOWN) {
|
||||||
|
stopScheduler('paused during lockdown');
|
||||||
|
logger.info('overseerControl paused due to lockdown mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
logger.info('overseerControl disabled');
|
||||||
|
updateStatus({ running: false, lastReason: 'overseerControl.enabled is false' });
|
||||||
|
} else {
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
stopScheduler('paused during lockdown');
|
||||||
|
logger.info('overseerControl paused on startup due to lockdown mode');
|
||||||
|
} else {
|
||||||
|
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
|
||||||
|
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getVoteStatus: () => status.voteStatus || buildVoteStatus(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
|
||||||
|
const STORE_PATH = resolveDataPath('overseer-control-memory.json');
|
||||||
|
const MAX_SLOT_LEN = 180;
|
||||||
|
const MAX_NOTE_LEN = 220;
|
||||||
|
const MAX_EVENT_LEN = 180;
|
||||||
|
const MAX_EVENTS = 20;
|
||||||
|
const MAX_NOTES = 24;
|
||||||
|
|
||||||
|
function createDefaultMemory() {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
slots: ['', '', ''],
|
||||||
|
notes: {},
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSlots(slots) {
|
||||||
|
const next = Array.isArray(slots) ? slots.slice(0, 3) : [];
|
||||||
|
while (next.length < 3) next.push('');
|
||||||
|
return next.map((entry) => String(entry || '').trim().slice(0, MAX_SLOT_LEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeNotes(notes) {
|
||||||
|
const input = notes && typeof notes === 'object' && !Array.isArray(notes) ? notes : {};
|
||||||
|
const entries = Object.entries(input)
|
||||||
|
.map(([key, value]) => [String(key || '').trim().slice(0, 48), String(value || '').trim().slice(0, MAX_NOTE_LEN)])
|
||||||
|
.filter(([key, value]) => key && value)
|
||||||
|
.slice(0, MAX_NOTES);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEvents(events) {
|
||||||
|
const input = Array.isArray(events) ? events : [];
|
||||||
|
return input
|
||||||
|
.slice(-MAX_EVENTS)
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') return null;
|
||||||
|
const text = String(entry.text || '').trim().slice(0, MAX_EVENT_LEN);
|
||||||
|
if (!text) return null;
|
||||||
|
const tags = Array.isArray(entry.tags)
|
||||||
|
? entry.tags.map((tag) => String(tag || '').trim().toLowerCase().slice(0, 20)).filter(Boolean).slice(0, 6)
|
||||||
|
: [];
|
||||||
|
const at = Number(entry.at) || Date.now();
|
||||||
|
return { at, text, tags };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMemory(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') return createDefaultMemory();
|
||||||
|
if (Array.isArray(raw?.slots) || Array.isArray(raw)) {
|
||||||
|
const slots = sanitizeSlots(Array.isArray(raw) ? raw : raw.slots);
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
slots,
|
||||||
|
notes: sanitizeNotes(raw?.notes),
|
||||||
|
events: sanitizeEvents(raw?.events),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
updatedAt: Number(raw.updatedAt) || Date.now(),
|
||||||
|
slots: sanitizeSlots(raw.slots),
|
||||||
|
notes: sanitizeNotes(raw.notes),
|
||||||
|
events: sanitizeEvents(raw.events),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMemory() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||||
|
return sanitizeMemory(raw);
|
||||||
|
} catch {
|
||||||
|
return createDefaultMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMemory(memory) {
|
||||||
|
const next = sanitizeMemory(memory);
|
||||||
|
const payload = { ...next, updatedAt: Date.now() };
|
||||||
|
fs.writeFileSync(STORE_PATH, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMemory(memory) {
|
||||||
|
const safe = sanitizeMemory(memory);
|
||||||
|
const lines = [];
|
||||||
|
lines.push('memory_slots:');
|
||||||
|
safe.slots.forEach((slot, idx) => lines.push(`- slot_${idx + 1}: ${slot || '(empty)'}`));
|
||||||
|
const noteEntries = Object.entries(safe.notes);
|
||||||
|
lines.push('memory_notes:');
|
||||||
|
if (!noteEntries.length) lines.push('- (none)');
|
||||||
|
noteEntries.slice(0, 10).forEach(([key, value]) => lines.push(`- ${key}: ${value}`));
|
||||||
|
lines.push('memory_events_recent:');
|
||||||
|
if (!safe.events.length) lines.push('- (none)');
|
||||||
|
safe.events
|
||||||
|
.slice(-5)
|
||||||
|
.forEach((evt) => lines.push(`- ${new Date(evt.at).toISOString()} | ${evt.tags.join(',') || 'misc'} | ${evt.text}`));
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadMemory,
|
||||||
|
saveMemory,
|
||||||
|
createDefaultMemory,
|
||||||
|
summarizeMemory,
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
function isAdminRole(role) {
|
||||||
|
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminState(status, runHistory) {
|
||||||
|
return {
|
||||||
|
runtime: {
|
||||||
|
running: status.running,
|
||||||
|
phase: status.phase,
|
||||||
|
phaseAt: status.phaseAt,
|
||||||
|
inFlight: status.inFlight,
|
||||||
|
tickCount: status.tickCount,
|
||||||
|
currentRunId: status.currentRunId,
|
||||||
|
lastTriggerReason: status.lastTriggerReason,
|
||||||
|
nextRunAt: status.nextRunAt,
|
||||||
|
lastTickAt: status.lastTickAt,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
enabled: status.enabled,
|
||||||
|
name: status.name,
|
||||||
|
model: status.model,
|
||||||
|
ollamaUrl: status.ollamaUrl,
|
||||||
|
gateIntervalMs: status.gateIntervalMs,
|
||||||
|
heartbeatMs: status.heartbeatMs,
|
||||||
|
alwaysRunModel: status.alwaysRunModel,
|
||||||
|
observeOnly: status.observeOnly,
|
||||||
|
promptPath: status.promptPath,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
systemPrompt: status.lastSystemPrompt,
|
||||||
|
stateUpdate: status.lastStateUpdate,
|
||||||
|
transcript: status.lastTranscript,
|
||||||
|
availableTools: status.lastAvailableTools,
|
||||||
|
blockedTools: status.lastBlockedTools,
|
||||||
|
modelMessages: status.lastModelMessages,
|
||||||
|
modelInputAt: status.lastModelInputAt,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
raw: status.lastModelRawOutput,
|
||||||
|
normalized: status.lastDecision,
|
||||||
|
chat: status.lastChatDraft,
|
||||||
|
actions: status.lastRequestedActions,
|
||||||
|
actionResults: status.lastActionResults,
|
||||||
|
liveToolCalls: status.lastLiveToolCalls || [],
|
||||||
|
outputAt: status.lastModelOutputAt,
|
||||||
|
outcome: status.lastOutcome,
|
||||||
|
reason: status.lastReason,
|
||||||
|
},
|
||||||
|
timings: {
|
||||||
|
lastGenerationMs: status.lastGenerationMs,
|
||||||
|
avgGenerationMs: status.avgGenerationMs,
|
||||||
|
generationCount: status.generationCount,
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
message: status.lastError,
|
||||||
|
details: status.lastErrorDetails,
|
||||||
|
failedAt: status.lastFailedAt,
|
||||||
|
},
|
||||||
|
history: runHistory,
|
||||||
|
debug: {
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
supportedActions: ['clearHistory'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOverseerOutput(rawContent = '') {
|
||||||
|
const raw = typeof rawContent === 'string' ? rawContent : '';
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { raw, decision: 'SKIP', chat: null, actions: [] };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
const decision = String(parsed?.decision || 'SKIP').toUpperCase();
|
||||||
|
const allowed = new Set(['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT']);
|
||||||
|
const nextDecision = allowed.has(decision) ? decision : 'SKIP';
|
||||||
|
const chat = typeof parsed?.chat === 'string' && parsed.chat.trim() ? parsed.chat.trim() : null;
|
||||||
|
const actions = Array.isArray(parsed?.actions)
|
||||||
|
? parsed.actions
|
||||||
|
.map((entry) => ({
|
||||||
|
tool: String(entry?.tool || '').trim(),
|
||||||
|
args: entry?.args && typeof entry.args === 'object' ? entry.args : {},
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.tool.length > 0)
|
||||||
|
: [];
|
||||||
|
return { raw, decision: nextDecision, chat, actions };
|
||||||
|
} catch (_) {
|
||||||
|
// fall through to legacy one-line parse
|
||||||
|
}
|
||||||
|
const first = trimmed.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
||||||
|
const upper = first.toUpperCase();
|
||||||
|
if (['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT'].includes(upper)) {
|
||||||
|
return { raw, decision: upper, chat: null, actions: [] };
|
||||||
|
}
|
||||||
|
return { raw, decision: 'CHAT', chat: first, actions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailureInfo(err) {
|
||||||
|
const message = err?.message || String(err || 'Unknown error');
|
||||||
|
const details = {
|
||||||
|
name: err?.name || null,
|
||||||
|
code: err?.code || null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isAdminRole,
|
||||||
|
buildAdminState,
|
||||||
|
parseOverseerOutput,
|
||||||
|
buildFailureInfo,
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'button_box_add_count',
|
||||||
|
signature: 'button_box_add_count(button_id, amount)',
|
||||||
|
description: 'Add progress count to a button box button.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
button_id: { type: 'integer', minimum: 1, maximum: 4 },
|
||||||
|
amount: { type: 'integer', minimum: 1, maximum: 25 },
|
||||||
|
},
|
||||||
|
required: ['button_id', 'amount'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, buttonBoxService }) {
|
||||||
|
const buttonId = Math.max(1, Math.min(4, Number(args?.button_id) || 0));
|
||||||
|
const amount = Math.max(1, Math.min(25, Number(args?.amount) || 0));
|
||||||
|
if (!buttonId) throw new Error('button_box_add_count requires args.button_id 1..4');
|
||||||
|
if (!amount) throw new Error('button_box_add_count requires args.amount 1..25');
|
||||||
|
const resp = await buttonBoxService.addButtonBoxCount(buttonId, amount);
|
||||||
|
return { ok: true, button: resp };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'chat_say',
|
||||||
|
signature: 'chat_say(text)',
|
||||||
|
description: 'Post a chat line as the Overseer bot.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
text: { type: 'string', minLength: 1 },
|
||||||
|
},
|
||||||
|
required: ['text'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability() {
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, sendSystemMessage, name }) {
|
||||||
|
const text = String(args?.text || '').trim();
|
||||||
|
if (!text) throw new Error('chat_say requires args.text');
|
||||||
|
sendSystemMessage(text, { nickname: name, bot: true });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'ha_set_entity',
|
||||||
|
signature: 'ha_set_entity(entity_id, state)',
|
||||||
|
description: 'Set Home Assistant controllable entity on/off.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string', minLength: 1 },
|
||||||
|
state: { type: 'string', enum: ['on', 'off'] },
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'state'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (ctx.homeAssistantState?.lightPolicy?.lockedOn) {
|
||||||
|
return { available: false, reason: 'policy_lock:lights_locked_on' };
|
||||||
|
}
|
||||||
|
if (!ctx.homeAssistantState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, homeAssistantService }) {
|
||||||
|
const entityId = String(args?.entity_id || args?.entityId || '').trim();
|
||||||
|
if (!entityId) throw new Error('ha_set_entity requires args.entity_id');
|
||||||
|
const allowed = new Set(
|
||||||
|
(homeAssistantService.getState()?.entities || []).map((entry) => String(entry?.id || '')).filter(Boolean),
|
||||||
|
);
|
||||||
|
if (!allowed.has(entityId)) throw new Error('ha_set_entity entity_id not configured');
|
||||||
|
const state = String(args?.state || '').toLowerCase();
|
||||||
|
if (state !== 'on' && state !== 'off') throw new Error('ha_set_entity requires args.state of on/off');
|
||||||
|
await homeAssistantService.setEntityState(entityId, state, { source: 'overseerControl' });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const memoryRead = require('./memoryRead');
|
||||||
|
const memoryWrite = require('./memoryWrite');
|
||||||
|
const memoryNoteUpsert = require('./memoryNoteUpsert');
|
||||||
|
const memoryNoteDelete = require('./memoryNoteDelete');
|
||||||
|
const memoryEventAdd = require('./memoryEventAdd');
|
||||||
|
const liftUp = require('./liftUp');
|
||||||
|
const liftDown = require('./liftDown');
|
||||||
|
const neatoStart = require('./neatoStart');
|
||||||
|
const neatoSendHome = require('./neatoSendHome');
|
||||||
|
const neatoLocate = require('./neatoLocate');
|
||||||
|
const neatoClearErrors = require('./neatoClearErrors');
|
||||||
|
const haSetEntity = require('./haSetEntity');
|
||||||
|
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
||||||
|
|
||||||
|
const TOOL_DEFINITIONS = [
|
||||||
|
memoryRead,
|
||||||
|
memoryWrite,
|
||||||
|
memoryNoteUpsert,
|
||||||
|
memoryNoteDelete,
|
||||||
|
memoryEventAdd,
|
||||||
|
liftUp,
|
||||||
|
liftDown,
|
||||||
|
neatoStart,
|
||||||
|
neatoSendHome,
|
||||||
|
neatoLocate,
|
||||||
|
neatoClearErrors,
|
||||||
|
haSetEntity,
|
||||||
|
buttonBoxAddCount,
|
||||||
|
];
|
||||||
|
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
||||||
|
|
||||||
|
function evaluateTools(context = {}) {
|
||||||
|
const available = [];
|
||||||
|
const availableIds = [];
|
||||||
|
const blocked = [];
|
||||||
|
TOOL_DEFINITIONS.forEach((tool) => {
|
||||||
|
const result = typeof tool.availability === 'function' ? tool.availability(context) : { available: false, reason: 'unavailable' };
|
||||||
|
if (result?.available) {
|
||||||
|
available.push(tool.signature);
|
||||||
|
availableIds.push(tool.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blocked.push({ id: tool.id, tool: tool.signature, reason: result?.reason || 'unavailable' });
|
||||||
|
});
|
||||||
|
return { available, availableIds, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOllamaTools(availableIds = []) {
|
||||||
|
const allowed = new Set((availableIds || []).map((id) => String(id)));
|
||||||
|
return TOOL_DEFINITIONS.filter((tool) => allowed.has(tool.id)).map((tool) => ({
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tool.id,
|
||||||
|
description: String(tool.description || tool.signature || tool.id),
|
||||||
|
parameters: tool.parameters || {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeToolAction(toolId, args = {}, context = {}) {
|
||||||
|
const key = String(toolId || '').trim();
|
||||||
|
if (!key) throw new Error('Missing tool id');
|
||||||
|
const tool = TOOL_BY_ID.get(key);
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error(`Unknown tool: ${key}`);
|
||||||
|
}
|
||||||
|
if (typeof tool.execute !== 'function') {
|
||||||
|
throw new Error(`Tool ${key} is not executable`);
|
||||||
|
}
|
||||||
|
return tool.execute({ ...context, args: args || {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolById(toolId) {
|
||||||
|
return TOOL_BY_ID.get(String(toolId || '').trim()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIdForSignature(signature) {
|
||||||
|
const sig = String(signature || '').trim();
|
||||||
|
const match = TOOL_DEFINITIONS.find((tool) => tool.signature === sig);
|
||||||
|
return match?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TOOL_DEFINITIONS,
|
||||||
|
evaluateTools,
|
||||||
|
buildOllamaTools,
|
||||||
|
executeToolAction,
|
||||||
|
getToolById,
|
||||||
|
getIdForSignature,
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'lift_down',
|
||||||
|
signature: 'lift_down()',
|
||||||
|
description: 'Move the lift downward.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.liftState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ liftService, actor = 'overseerControl' }) {
|
||||||
|
const resp = await liftService.moveDown(actor);
|
||||||
|
return { ok: true, resp };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'lift_up',
|
||||||
|
signature: 'lift_up()',
|
||||||
|
description: 'Move the lift upward.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.liftState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ liftService, actor = 'overseerControl' }) {
|
||||||
|
const resp = await liftService.moveUp(actor);
|
||||||
|
return { ok: true, resp };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'memory_event_add',
|
||||||
|
signature: 'memory_event_add(text, tags?)',
|
||||||
|
description: 'Append a short recent-event memory with optional tags.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
text: { type: 'string', minLength: 1, maxLength: 180 },
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', minLength: 1, maxLength: 20 },
|
||||||
|
minItems: 0,
|
||||||
|
maxItems: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['text'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability() {
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, memoryStore }) {
|
||||||
|
const text = String(args?.text || '').trim().slice(0, 180);
|
||||||
|
if (!text) throw new Error('memory_event_add requires args.text');
|
||||||
|
const tags = Array.isArray(args?.tags)
|
||||||
|
? args.tags.map((tag) => String(tag || '').trim().toLowerCase().slice(0, 20)).filter(Boolean).slice(0, 6)
|
||||||
|
: [];
|
||||||
|
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||||
|
const events = Array.isArray(next.events) ? [...next.events] : [];
|
||||||
|
events.push({ at: Date.now(), text, tags });
|
||||||
|
next.events = events.slice(-20);
|
||||||
|
return { ok: true, memory: next };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'memory_note_delete',
|
||||||
|
signature: 'memory_note_delete(key)',
|
||||||
|
description: 'Delete a durable named note by key.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: { type: 'string', minLength: 1, maxLength: 48 },
|
||||||
|
},
|
||||||
|
required: ['key'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability() {
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, memoryStore }) {
|
||||||
|
const key = String(args?.key || '').trim().toLowerCase().replace(/[^a-z0-9_\-]/g, '_').slice(0, 48);
|
||||||
|
if (!key) throw new Error('memory_note_delete requires args.key');
|
||||||
|
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||||
|
const notes = next.notes && typeof next.notes === 'object' && !Array.isArray(next.notes) ? { ...next.notes } : {};
|
||||||
|
delete notes[key];
|
||||||
|
next.notes = notes;
|
||||||
|
return { ok: true, memory: next, key };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'memory_note_upsert',
|
||||||
|
signature: 'memory_note_upsert(key, text)',
|
||||||
|
description: 'Upsert a durable named note for stable facts/preferences. Key is short snake_case.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
key: { type: 'string', minLength: 1, maxLength: 48 },
|
||||||
|
text: { type: 'string', minLength: 1, maxLength: 220 },
|
||||||
|
},
|
||||||
|
required: ['key', 'text'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability() {
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, memoryStore }) {
|
||||||
|
const key = String(args?.key || '').trim().toLowerCase().replace(/[^a-z0-9_\-]/g, '_').slice(0, 48);
|
||||||
|
const text = String(args?.text || '').trim().slice(0, 220);
|
||||||
|
if (!key) throw new Error('memory_note_upsert requires args.key');
|
||||||
|
if (!text) throw new Error('memory_note_upsert requires args.text');
|
||||||
|
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||||
|
const notes = next.notes && typeof next.notes === 'object' && !Array.isArray(next.notes) ? { ...next.notes } : {};
|
||||||
|
notes[key] = text;
|
||||||
|
next.notes = notes;
|
||||||
|
return { ok: true, memory: next, key };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'memory_read',
|
||||||
|
signature: 'memory_read()',
|
||||||
|
description: 'Read full persistent memory (slots, notes, recent events).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability() {
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ memoryStore }) {
|
||||||
|
return { ok: true, memory: memoryStore };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'memory_write',
|
||||||
|
signature: 'memory_write(slot, text)',
|
||||||
|
description: 'Write one of 3 scratchpad slots for short-term reminders.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
slot: { type: 'integer', minimum: 1, maximum: 3 },
|
||||||
|
text: { type: 'string', minLength: 1, maxLength: 180 },
|
||||||
|
},
|
||||||
|
required: ['slot', 'text'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability() {
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ args = {}, memoryStore }) {
|
||||||
|
const slot = Math.max(1, Math.min(3, Number(args?.slot) || 0));
|
||||||
|
if (!slot) throw new Error('memory_write requires args.slot 1..3');
|
||||||
|
const text = String(args?.text || '').trim();
|
||||||
|
if (!text) throw new Error('memory_write requires args.text');
|
||||||
|
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
|
||||||
|
const slots = Array.isArray(next.slots) ? [...next.slots] : ['', '', ''];
|
||||||
|
while (slots.length < 3) slots.push('');
|
||||||
|
slots[slot - 1] = text.slice(0, 180);
|
||||||
|
next.slots = slots.slice(0, 3);
|
||||||
|
return { ok: true, memory: next };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'neato_clear_errors',
|
||||||
|
signature: 'neato_clear_errors()',
|
||||||
|
description: 'Clear Neato errors.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ neatoService }) {
|
||||||
|
await neatoService.clearErrors();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'neato_play_sound',
|
||||||
|
signature: 'neato_play_sound()',
|
||||||
|
description: 'Play a sound/chime on the Neato robot.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ neatoService }) {
|
||||||
|
await neatoService.locateRobot();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'neato_send_home',
|
||||||
|
signature: 'neato_send_home()',
|
||||||
|
description: 'Send Neato back to base.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ neatoService }) {
|
||||||
|
await neatoService.sendHome();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
id: 'neato_start',
|
||||||
|
signature: 'neato_start()',
|
||||||
|
description: 'Start Neato cleaning cycle.',
|
||||||
|
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
availability(ctx = {}) {
|
||||||
|
const mode = String(ctx.mode || '');
|
||||||
|
if (mode === 'admin' || mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
|
||||||
|
return { available: true, reason: null };
|
||||||
|
},
|
||||||
|
async execute({ neatoService }) {
|
||||||
|
await neatoService.startCleaning();
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ const FFMPEG_BIN = process.env.FFMPEG_BIN || 'ffmpeg';
|
|||||||
const SEGMENT_ROOT = path.join(resolveDataDir(), 'replay-segments');
|
const SEGMENT_ROOT = path.join(resolveDataDir(), 'replay-segments');
|
||||||
const SEGMENT_SECONDS = Math.max(1, Number.parseInt(process.env.REPLAY_SEGMENT_SECONDS || '1', 10));
|
const SEGMENT_SECONDS = Math.max(1, Number.parseInt(process.env.REPLAY_SEGMENT_SECONDS || '1', 10));
|
||||||
const BUFFER_SECONDS = Math.max(20, Number.parseInt(process.env.REPLAY_BUFFER_SECONDS || '45', 10));
|
const BUFFER_SECONDS = Math.max(20, Number.parseInt(process.env.REPLAY_BUFFER_SECONDS || '45', 10));
|
||||||
const CLEANUP_INTERVAL_MS = 10_000;
|
const CLEANUP_INTERVAL_MS = Math.max(500, Number.parseInt(process.env.REPLAY_TICK_MS || '2000', 10));
|
||||||
const BUILD_DURATION_MS = Math.max(5000, Number.parseInt(process.env.REPLAY_DURATION_MS || '35000', 10));
|
const BUILD_DURATION_MS = Math.max(5000, Number.parseInt(process.env.REPLAY_DURATION_MS || '35000', 10));
|
||||||
const BUILD_GUARD_MS = Math.max(200, Number.parseInt(process.env.REPLAY_GUARD_MS || '1200', 10));
|
const BUILD_GUARD_MS = Math.max(200, Number.parseInt(process.env.REPLAY_GUARD_MS || '1200', 10));
|
||||||
const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS || '30', 10));
|
const TARGET_FPS = Math.max(10, Number.parseInt(process.env.REPLAY_TARGET_FPS || '30', 10));
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ const replayBuilder = createReplayBuilder({
|
|||||||
});
|
});
|
||||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||||
|
|
||||||
|
async function buildReplayVideoFresh(options = {}) {
|
||||||
|
await tick();
|
||||||
|
return replayBuilder.buildReplayVideo(options);
|
||||||
|
}
|
||||||
|
|
||||||
function getReplayHealthSnapshot() {
|
function getReplayHealthSnapshot() {
|
||||||
return segmentStore.getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras });
|
return segmentStore.getReplayHealthSnapshot({ BUILD_DURATION_MS, roverManager, getRoomCameras });
|
||||||
}
|
}
|
||||||
@@ -98,7 +103,7 @@ start().catch((err) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildReplayVideo: replayBuilder.buildReplayVideo,
|
buildReplayVideo: buildReplayVideoFresh,
|
||||||
tryTriggerReplay,
|
tryTriggerReplay,
|
||||||
getReplayState,
|
getReplayState,
|
||||||
replayEvents,
|
replayEvents,
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForRover, overlapping }) {
|
||||||
|
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||||
|
const tentativeEnd = nowMs - guardMs;
|
||||||
|
const sourceEnds = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
const sourceId = String(source.id);
|
||||||
|
const allEntries = getVideoEntriesForSource({ type: String(source.type), id: sourceId });
|
||||||
|
if (!Array.isArray(allEntries) || !allEntries.length) continue;
|
||||||
|
const bounded = allEntries.filter((entry) => Number.isFinite(entry?.endMs) && entry.endMs <= tentativeEnd);
|
||||||
|
if (!bounded.length) continue;
|
||||||
|
const latest = bounded.reduce((best, entry) => (entry.endMs > best.endMs ? entry : best), bounded[0]);
|
||||||
|
sourceEnds.push(latest.endMs);
|
||||||
|
}
|
||||||
|
const alignedEnd = sourceEnds.length ? Math.min(...sourceEnds) : tentativeEnd;
|
||||||
|
return { tEnd: alignedEnd, tStart: alignedEnd - durationMs };
|
||||||
|
}
|
||||||
|
|
||||||
async function concatFiles(inputPaths, outPath) {
|
async function concatFiles(inputPaths, outPath) {
|
||||||
const listPath = `${outPath}.concat.txt`;
|
const listPath = `${outPath}.concat.txt`;
|
||||||
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
const body = inputPaths.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join('\n');
|
||||||
@@ -74,8 +90,12 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
|
|
||||||
async function buildReplayVideo({ sources = [], title = '', requester = '', includeSidebar = true } = {}) {
|
async function buildReplayVideo({ sources = [], title = '', requester = '', includeSidebar = true } = {}) {
|
||||||
if (!Array.isArray(sources) || !sources.length) throw new Error('No replay sources selected');
|
if (!Array.isArray(sources) || !sources.length) throw new Error('No replay sources selected');
|
||||||
const tEnd = Date.now() - BUILD_GUARD_MS;
|
const { tEnd, tStart } = resolveReplayWindow({
|
||||||
const tStart = tEnd - BUILD_DURATION_MS;
|
sources,
|
||||||
|
nowMs: Date.now(),
|
||||||
|
guardMs: BUILD_GUARD_MS,
|
||||||
|
durationMs: BUILD_DURATION_MS,
|
||||||
|
});
|
||||||
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
const resolvedTitle = sanitizeReplayTitle(title, resolveDefaultReplayTitle(requester, sources));
|
||||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mrr-replay-v2-'));
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ function createRosterLifecycle(deps) {
|
|||||||
return Array.from(rovers.values()).map((record) => ({
|
return Array.from(rovers.values()).map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.meta?.name || record.id,
|
name: record.meta?.name || record.id,
|
||||||
|
description: record.meta?.description,
|
||||||
color: record.meta?.color || null,
|
color: record.meta?.color || null,
|
||||||
battery: record.meta?.battery,
|
battery: record.meta?.battery,
|
||||||
batteryState: record.batteryState,
|
batteryState: record.batteryState,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
|||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||||
|
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
|
||||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||||
const {
|
const {
|
||||||
getVerificationStateForSocket,
|
getVerificationStateForSocket,
|
||||||
@@ -66,6 +67,7 @@ function buildUserEntry(socket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSession(socket) {
|
function buildSession(socket) {
|
||||||
|
const overseerVote = getOverseerVoteStatus();
|
||||||
const users = Array.from(io.sockets.sockets.values())
|
const users = Array.from(io.sockets.sockets.values())
|
||||||
.map((sock) => buildUserEntry(sock))
|
.map((sock) => buildUserEntry(sock))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -124,6 +126,10 @@ function buildSession(socket) {
|
|||||||
audioForward: getAudioForwardState(),
|
audioForward: getAudioForwardState(),
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: getAudioLevels(),
|
||||||
buttonBox: getButtonBoxState(),
|
buttonBox: getButtonBoxState(),
|
||||||
|
overseerVote: {
|
||||||
|
...overseerVote,
|
||||||
|
preference: typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,11 +111,18 @@ function identifySocket(socket, payload = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, 'overseerEnabled')) {
|
||||||
|
data.overseerEnabled = Boolean(payload.overseerEnabled);
|
||||||
|
} else if (typeof data.overseerEnabled !== 'boolean') {
|
||||||
|
data.overseerEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
const verification = reevaluateSocketVerification(socket);
|
const verification = reevaluateSocketVerification(socket);
|
||||||
const deterrence = reevaluateSocketDeterrence(socket);
|
const deterrence = reevaluateSocketDeterrence(socket);
|
||||||
emitChange('identify', { socketId: socket.id });
|
emitChange('identify', { socketId: socket.id });
|
||||||
return {
|
return {
|
||||||
cookieUserId: data.cookieUserId,
|
cookieUserId: data.cookieUserId,
|
||||||
|
overseerEnabled: Boolean(data.overseerEnabled),
|
||||||
isVerified: verification.isVerified,
|
isVerified: verification.isVerified,
|
||||||
isDeterred: deterrence.isDeterred,
|
isDeterred: deterrence.isDeterred,
|
||||||
reason: verification.reason,
|
reason: verification.reason,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ function createVerificationFlow(deps) {
|
|||||||
return {
|
return {
|
||||||
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
cookieUserId: normalizeCookieUserId(data.cookieUserId) || null,
|
||||||
nickname: getNickname(socket) || null,
|
nickname: getNickname(socket) || null,
|
||||||
|
overseerEnabled: typeof data.overseerEnabled === 'boolean' ? data.overseerEnabled : true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
<meta name="theme-color" content="#020617" />
|
<meta name="theme-color" content="#020617" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+2
-1
@@ -28,6 +28,7 @@
|
|||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"vite": "^7.2.2"
|
"vite": "^7.2.2",
|
||||||
|
"socket.io-client": "4.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-15
@@ -16,18 +16,21 @@ import {
|
|||||||
} from './controls/index.js';
|
} from './controls/index.js';
|
||||||
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
||||||
import LogPanel from './components/LogPanel/index.jsx';
|
import LogPanel from './components/LogPanel/index.jsx';
|
||||||
import DriverVideoPanel from './components/DriverVideoPanel/index.jsx';
|
import DriverVideo from './components/DriverVideo/index.jsx';
|
||||||
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
|
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
|
||||||
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
|
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
|
||||||
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
|
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
|
||||||
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
|
import TurnAlertListener from './components/TurnAlertListener/index.jsx';
|
||||||
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
|
import RawUserPilePanel from './components/RawUserPilePanel/index.jsx';
|
||||||
|
import OverseerPreferencePanel from './components/OverseerPreferencePanel/index.jsx';
|
||||||
|
import SocialButtonsGrid from './components/SocialButtonsGrid/index.jsx';
|
||||||
import ChatPanel from './components/ChatPanel/index.jsx';
|
import ChatPanel from './components/ChatPanel/index.jsx';
|
||||||
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
|
import FullscreenPrompt from './components/FullscreenPrompt/index.jsx';
|
||||||
import FloatingFullscreenButton from './components/FloatingFullscreenButton/index.jsx';
|
import FloatingFullscreenButton from './components/FloatingFullscreenButton/index.jsx';
|
||||||
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
|
||||||
import { useSettingsNamespace } from './settings/index.js';
|
import { useSettingsNamespace } from './settings/index.js';
|
||||||
import HelpOverlay from './components/HelpOverlay/index.jsx';
|
import HelpOverlay from './components/HelpOverlay/index.jsx';
|
||||||
|
import QuickstartOverlay from './components/QuickstartOverlay/index.jsx';
|
||||||
import HelpPanel from './components/HelpPanel/index.jsx';
|
import HelpPanel from './components/HelpPanel/index.jsx';
|
||||||
import SettingsPanel from './components/SettingsPanel/index.jsx';
|
import SettingsPanel from './components/SettingsPanel/index.jsx';
|
||||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
|
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
|
||||||
@@ -74,7 +77,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-0.5 overflow-hidden">
|
<div className="flex h-full gap-0.5 overflow-hidden">
|
||||||
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
|
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
|
||||||
<DriverVideoPanel />
|
<DriverVideo />
|
||||||
<TelemetryPanel />
|
<TelemetryPanel />
|
||||||
<LogPanel />
|
<LogPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -140,8 +143,16 @@ function MobileFeatureTabs({
|
|||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel id="chat">
|
<TabPanel id="chat">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<ChatPanel />
|
<ChatPanel nicknameLayout="stacked" />
|
||||||
<RawUserPilePanel />
|
<div className="space-y-0.5">
|
||||||
|
<div className="grid gap-0.5 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||||
|
<SocialButtonsGrid />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
<OverseerPreferencePanel />
|
||||||
|
<RawUserPilePanel hideNicknameForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="vip" keepMounted>
|
<TabPanel id="vip" keepMounted>
|
||||||
@@ -173,7 +184,7 @@ function MobileFeatureTabs({
|
|||||||
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<DriverVideoPanel layoutFormat="mobile-portrait" />
|
<DriverVideo layoutFormat="mobile-portrait" />
|
||||||
<MobileControls swapColumns={swapMobileControlColumns} />
|
<MobileControls swapColumns={swapMobileControlColumns} />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||||
@@ -202,7 +213,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
|
|||||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
||||||
{firstColumn}
|
{firstColumn}
|
||||||
<div>
|
<div>
|
||||||
<DriverVideoPanel layoutFormat="mobile-landscape" />
|
<DriverVideo layoutFormat="mobile-landscape" />
|
||||||
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||||
<RoverQueuesPanel />
|
<RoverQueuesPanel />
|
||||||
@@ -250,9 +261,13 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
value: helpSettings,
|
value: helpSettings,
|
||||||
status: helpStatus,
|
|
||||||
save: saveHelpSettings,
|
save: saveHelpSettings,
|
||||||
} = useSettingsNamespace('help', { showOnLoad: true });
|
} = useSettingsNamespace('help', { showOnLoad: true });
|
||||||
|
const {
|
||||||
|
value: quickstartSettings,
|
||||||
|
status: quickstartStatus,
|
||||||
|
save: saveQuickstartSettings,
|
||||||
|
} = useSettingsNamespace('quickstart', { showOnLoad: true });
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||||
swapMobileControlColumns: false,
|
swapMobileControlColumns: false,
|
||||||
});
|
});
|
||||||
@@ -260,15 +275,17 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
||||||
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
||||||
const [helpVisible, setHelpVisible] = useState(false);
|
const [helpVisible, setHelpVisible] = useState(false);
|
||||||
|
const [quickstartVisible, setQuickstartVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (helpStatus === 'ready') {
|
if (quickstartStatus === 'ready') {
|
||||||
setHelpVisible(helpSettings?.showOnLoad !== false);
|
setQuickstartVisible(quickstartSettings?.showOnLoad !== false);
|
||||||
}
|
}
|
||||||
}, [helpStatus, helpSettings?.showOnLoad]);
|
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
|
||||||
|
|
||||||
const openHelp = useCallback(() => setHelpVisible(true), []);
|
const openHelp = useCallback(() => setHelpVisible(true), []);
|
||||||
const closeHelp = useCallback(() => setHelpVisible(false), []);
|
const closeHelp = useCallback(() => setHelpVisible(false), []);
|
||||||
|
const closeQuickstart = useCallback(() => setQuickstartVisible(false), []);
|
||||||
const handleFloatingFullscreen = useCallback(async () => {
|
const handleFloatingFullscreen = useCallback(async () => {
|
||||||
if (fullscreenIsIOS) {
|
if (fullscreenIsIOS) {
|
||||||
showPrompt();
|
showPrompt();
|
||||||
@@ -279,16 +296,20 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
showPrompt();
|
showPrompt();
|
||||||
}
|
}
|
||||||
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
|
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
|
||||||
const setShowOnLoad = useCallback(
|
const setQuickstartShowOnLoad = useCallback(
|
||||||
(enabled) => {
|
(enabled) => {
|
||||||
const next = Boolean(enabled);
|
const next = Boolean(enabled);
|
||||||
saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setHelpVisible(false);
|
setQuickstartVisible(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[saveHelpSettings],
|
[saveQuickstartSettings],
|
||||||
);
|
);
|
||||||
|
const openHelpFromQuickstart = useCallback(() => {
|
||||||
|
setQuickstartVisible(false);
|
||||||
|
setHelpVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderedLayout = useMemo(
|
const renderedLayout = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -312,12 +333,13 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
<RewardRunOverlay />
|
<RewardRunOverlay />
|
||||||
<TurnAlertListener />
|
<TurnAlertListener />
|
||||||
<ModeGateOverlay />
|
<ModeGateOverlay />
|
||||||
|
|
||||||
<HelpOverlay
|
<HelpOverlay
|
||||||
visible={helpVisible}
|
visible={helpVisible}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
onClose={closeHelp}
|
onClose={closeHelp}
|
||||||
showOnLoad={helpSettings?.showOnLoad !== false}
|
showOnLoad={helpSettings?.showOnLoad !== false}
|
||||||
onToggleShowOnLoad={setShowOnLoad}
|
onToggleShowOnLoad={(enabled) => saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: Boolean(enabled) }))}
|
||||||
/>
|
/>
|
||||||
<FullscreenPrompt
|
<FullscreenPrompt
|
||||||
visible={fullscreenVisible}
|
visible={fullscreenVisible}
|
||||||
@@ -325,6 +347,14 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
|||||||
onEnterFullscreen={enterFullscreen}
|
onEnterFullscreen={enterFullscreen}
|
||||||
onDismiss={dismiss}
|
onDismiss={dismiss}
|
||||||
/>
|
/>
|
||||||
|
<QuickstartOverlay
|
||||||
|
visible={quickstartVisible}
|
||||||
|
layout={layout}
|
||||||
|
showOnLoad={quickstartSettings?.showOnLoad !== false}
|
||||||
|
onToggleShowOnLoad={setQuickstartShowOnLoad}
|
||||||
|
onOpenHelp={openHelpFromQuickstart}
|
||||||
|
onClose={closeQuickstart}
|
||||||
|
/>
|
||||||
{showFloatingFullscreenButton ? (
|
{showFloatingFullscreenButton ? (
|
||||||
<FloatingFullscreenButton
|
<FloatingFullscreenButton
|
||||||
side={fullscreenButtonSide}
|
side={fullscreenButtonSide}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useSession } from '../../context/SessionContext.jsx';
|
import { useSession } from '../../context/SessionContext.jsx';
|
||||||
import RoverRoster from '../RoverRoster/index.jsx';
|
import RoverRoster from '../RoverRoster/index.jsx';
|
||||||
import LlmCommentaryPanel from './LlmCommentaryPanel.jsx';
|
import LlmCommentaryPanel from './LlmCommentaryPanel.jsx';
|
||||||
|
import OverseerControlPanel from './OverseerControlPanel.jsx';
|
||||||
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
|
||||||
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
|
||||||
|
|
||||||
@@ -28,14 +29,17 @@ export default function AdminPanelContent() {
|
|||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
setPrivateSafety,
|
setPrivateSafety,
|
||||||
llmControl,
|
llmControl,
|
||||||
|
overseerControl,
|
||||||
adminLogs,
|
adminLogs,
|
||||||
llmCommentaryState,
|
llmCommentaryState,
|
||||||
|
overseerControlState,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
const [rebootStates, setRebootStates] = useState({});
|
const [rebootStates, setRebootStates] = useState({});
|
||||||
const [serverRebooting, setServerRebooting] = useState(false);
|
const [serverRebooting, setServerRebooting] = useState(false);
|
||||||
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
||||||
|
const [clearingOverseerHistory, setClearingOverseerHistory] = useState(false);
|
||||||
const health = session?.health || null;
|
const health = session?.health || null;
|
||||||
const currentGoal = session?.globalObjective?.text || '';
|
const currentGoal = session?.globalObjective?.text || '';
|
||||||
const goalUpdatedAt = session?.globalObjective?.updatedAt || null;
|
const goalUpdatedAt = session?.globalObjective?.updatedAt || null;
|
||||||
@@ -126,6 +130,19 @@ export default function AdminPanelContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearOverseerHistory = async () => {
|
||||||
|
const ok = window.confirm('Clear Overseer Control history now?');
|
||||||
|
if (!ok) return;
|
||||||
|
setClearingOverseerHistory(true);
|
||||||
|
try {
|
||||||
|
await overseerControl('clearHistory');
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setClearingOverseerHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleGoalSave = async () => {
|
const handleGoalSave = async () => {
|
||||||
try {
|
try {
|
||||||
await setGlobalObjective(goalDraft);
|
await setGlobalObjective(goalDraft);
|
||||||
@@ -491,6 +508,11 @@ export default function AdminPanelContent() {
|
|||||||
onClearHistory={handleClearLlmHistory}
|
onClearHistory={handleClearLlmHistory}
|
||||||
clearingHistory={clearingLlmHistory}
|
clearingHistory={clearingLlmHistory}
|
||||||
/>
|
/>
|
||||||
|
<OverseerControlPanel
|
||||||
|
state={overseerControlState}
|
||||||
|
onClearHistory={handleClearOverseerHistory}
|
||||||
|
clearingHistory={clearingOverseerHistory}
|
||||||
|
/>
|
||||||
<AdminIpLogPanel entries={adminLogs} />
|
<AdminIpLogPanel entries={adminLogs} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function OverseerControlPanel({ state, onClearHistory, clearingHistory }) {
|
||||||
|
const [showPayload, setShowPayload] = useState(false);
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Overseer Control</div>
|
||||||
|
<div className="surface text-xs text-slate-300">No status received yet.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = state.runtime || {};
|
||||||
|
const cfg = state.config || {};
|
||||||
|
const output = state.output || {};
|
||||||
|
const timings = state.timings || {};
|
||||||
|
const input = state.input || {};
|
||||||
|
const errors = state.errors || {};
|
||||||
|
const renderModelMessages = () => {
|
||||||
|
const messages = Array.isArray(input.modelMessages) ? input.modelMessages : [];
|
||||||
|
if (!messages.length) return '<none>';
|
||||||
|
return messages
|
||||||
|
.map((msg, idx) => {
|
||||||
|
const role = String(msg?.role || 'unknown').toUpperCase();
|
||||||
|
const content = String(msg?.content || '');
|
||||||
|
return `#${idx + 1} ${role}\n${content}`;
|
||||||
|
})
|
||||||
|
.join('\n\n----------------------------------------\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="panel-muted text-xs uppercase">Overseer Control</div>
|
||||||
|
<div className="flex gap-0.5 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearHistory}
|
||||||
|
disabled={Boolean(clearingHistory)}
|
||||||
|
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{clearingHistory ? 'Clearing...' : 'Clear Overseer History'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="surface flex flex-wrap gap-0.5 text-xs">
|
||||||
|
<span className="surface-muted">running: {runtime.running ? 'yes' : 'no'}</span>
|
||||||
|
<span className="surface-muted">phase: {runtime.phase || '--'}</span>
|
||||||
|
<span className="surface-muted">tick: {runtime.tickCount ?? 0}</span>
|
||||||
|
<span className="surface-muted">trigger: {runtime.lastTriggerReason || '--'}</span>
|
||||||
|
<span className="surface-muted">name: {cfg.name || '--'}</span>
|
||||||
|
<span className="surface-muted">model: {cfg.model || '--'}</span>
|
||||||
|
<span className="surface-muted">observeOnly: {cfg.observeOnly ? 'yes' : 'no'}</span>
|
||||||
|
<span className="surface-muted">decision: {output.normalized || '--'}</span>
|
||||||
|
<span className="surface-muted">outcome: {output.outcome || '--'}</span>
|
||||||
|
<span className="surface-muted">reason: {output.reason || '--'}</span>
|
||||||
|
<span className="surface-muted">last gen: {timings.lastGenerationMs != null ? `${timings.lastGenerationMs}ms` : '--'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200" open>
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{input.stateUpdate || '<none>'}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200" open>
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{renderModelMessages()}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200">
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{input.systemPrompt || '<none>'}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200">
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{JSON.stringify(input.transcript || [], null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200">
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200" open>
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{output.raw || '<none>'}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200" open>
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{JSON.stringify(output.liveToolCalls || [], null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="surface text-xs text-slate-200">
|
||||||
|
<div>Normalized decision: {output.normalized || '--'}</div>
|
||||||
|
<div>Model input at: {input.modelInputAt ? new Date(input.modelInputAt).toLocaleString() : 'n/a'}</div>
|
||||||
|
<div>Model output at: {output.outputAt ? new Date(output.outputAt).toLocaleString() : 'n/a'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors.message ? <div className="surface text-xs text-red-300">Error: {errors.message}</div> : null}
|
||||||
|
|
||||||
|
<details className="surface text-xs text-slate-200">
|
||||||
|
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
|
||||||
|
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
|
||||||
|
{JSON.stringify(state.history || [], null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<button type="button" className="button-dark text-xs" onClick={() => setShowPayload((v) => !v)}>
|
||||||
|
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
|
||||||
|
</button>
|
||||||
|
{showPayload ? (
|
||||||
|
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,7 +62,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
|||||||
nickname,
|
nickname,
|
||||||
text: content,
|
text: content,
|
||||||
role: 'spectator',
|
role: 'spectator',
|
||||||
system: role === 'system',
|
bot: role === 'system',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
|
|||||||
nickname: 'LLM Output',
|
nickname: 'LLM Output',
|
||||||
text: raw.trim() ? raw : '<empty>',
|
text: raw.trim() ? raw : '<empty>',
|
||||||
role: 'spectator',
|
role: 'spectator',
|
||||||
system: true,
|
bot: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSocket } from '../../context/SocketContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
const FLASH_MS = 420;
|
const FLASH_MS = 420;
|
||||||
const REWARD_FLASH_MS = 1200;
|
const REWARD_FLASH_MS = 1200;
|
||||||
@@ -126,10 +127,7 @@ export default function ButtonBoxPanel() {
|
|||||||
}, [effectiveAlertVolume, socket]);
|
}, [effectiveAlertVolume, socket]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
||||||
<header className="panel-muted text-center text-sm">
|
|
||||||
<p>Button Box</p>
|
|
||||||
</header>
|
|
||||||
<div className="grid grid-cols-4 gap-0.5">
|
<div className="grid grid-cols-4 gap-0.5">
|
||||||
{buttons.map((button) => {
|
{buttons.map((button) => {
|
||||||
const id = Number(button.id);
|
const id = Number(button.id);
|
||||||
@@ -155,6 +153,6 @@ export default function ButtonBoxPanel() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
function cx(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color helpers
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component
|
||||||
|
export default function CardFrame({
|
||||||
|
title = '',
|
||||||
|
meta = null,
|
||||||
|
actions = null,
|
||||||
|
hideHeader = false,
|
||||||
|
className = '',
|
||||||
|
headerClassName = '',
|
||||||
|
bodyClassName = '',
|
||||||
|
fillHeight = false,
|
||||||
|
clipOverflow = true,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const showHeader = !hideHeader && (title || meta != null || actions);
|
||||||
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.35) } : undefined;
|
||||||
|
const headerStyle = accentRgb
|
||||||
|
? {
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cx(
|
||||||
|
'panel-section border border-neutral-500/60 bg-neutral-900/95 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
|
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
|
||||||
|
fillHeight && 'flex h-full min-h-0 flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={cardStyle}
|
||||||
|
>
|
||||||
|
{showHeader ? (
|
||||||
|
// Header row
|
||||||
|
<header
|
||||||
|
className={cx(
|
||||||
|
'flex items-center justify-between gap-0.5 border-b border-neutral-500/50 bg-gradient-to-r from-neutral-800 via-neutral-700 to-neutral-600 px-0.5 py-0.5',
|
||||||
|
headerClassName,
|
||||||
|
)}
|
||||||
|
style={headerStyle}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-0.5">
|
||||||
|
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
|
||||||
|
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className={cx(fillHeight && 'flex flex-1 min-h-0 flex-col', bodyClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
// Chat Message Row
|
// Chat Message Row
|
||||||
// Purpose: Defines the Chat Message Row module and the local helpers/components used in this file.
|
// Purpose: Defines the Chat Message Row module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
|
import { useState } from 'react';
|
||||||
import { FaDiscord } from 'react-icons/fa';
|
import { FaDiscord } from 'react-icons/fa';
|
||||||
import { roverBadgeStyle } from '../../lib/roverColor.js';
|
import { roverBadgeStyle } from '../../lib/roverColor.js';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
function roleColors(role) {
|
function roleColors(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -17,8 +19,8 @@ function roleColors(role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBotSystemMessage(message) {
|
function isBotMessage(message) {
|
||||||
return Boolean(message?.system);
|
return Boolean(message?.bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
@@ -34,7 +36,7 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
|||||||
if (!guildIconUrl && !userAvatarUrl) return null;
|
if (!guildIconUrl && !userAvatarUrl) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
|
className="flex h-4 w-4 shrink-0 overflow-hidden rounded-none border border-slate-700/80"
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -65,51 +67,65 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatIdentity({ message }) {
|
function ProfileAvatar({ imageUrl, label }) {
|
||||||
|
if (!imageUrl) return null;
|
||||||
|
return (
|
||||||
|
<span className="flex h-4 w-4 shrink-0 overflow-hidden rounded-none border border-slate-700/80" title={label}>
|
||||||
|
<span
|
||||||
|
className="h-full w-full bg-cover bg-center bg-no-repeat"
|
||||||
|
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatIdentity({ message, toolsToggle = null }) {
|
||||||
const discordLabel = message.fromDiscord
|
const discordLabel = message.fromDiscord
|
||||||
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
||||||
: null;
|
: null;
|
||||||
const isBot = isBotSystemMessage(message);
|
const isBot = isBotMessage(message);
|
||||||
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
|
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
|
||||||
return (
|
return (
|
||||||
<>
|
<span className="inline-flex min-w-0 items-center gap-0.5 align-middle">
|
||||||
{message.fromDiscord ? (
|
{message.fromDiscord ? (
|
||||||
<>
|
<FaDiscord className="h-4 w-4 shrink-0 text-indigo-200" />
|
||||||
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
|
|
||||||
<DiscordAvatar
|
|
||||||
guildIconUrl={message.discordGuildIconUrl}
|
|
||||||
userAvatarUrl={message.discordUserAvatarUrl}
|
|
||||||
label={discordLabel}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
|
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
|
||||||
{displayName(message)}
|
{displayName(message)}
|
||||||
</span>
|
</span>
|
||||||
|
<ProfileAvatar imageUrl={message.profileImage} label={discordLabel || displayName(message)} />
|
||||||
|
{!message.profileImage ? (
|
||||||
|
<DiscordAvatar
|
||||||
|
guildIconUrl={message.discordGuildIconUrl}
|
||||||
|
userAvatarUrl={message.discordUserAvatarUrl}
|
||||||
|
label={discordLabel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{isBot ? (
|
{isBot ? (
|
||||||
<span className="rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
<span className="shrink-0 rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-white">
|
||||||
bot
|
bot
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{message.roverId && (
|
{message.roverId && (
|
||||||
<span
|
<span
|
||||||
className="rounded bg-slate-800 px-1 text-[0.7rem]"
|
className="shrink-0 rounded bg-slate-800 px-1 text-[0.7rem]"
|
||||||
style={roverBadgeStyle(message.roverColor, 0.14)}
|
style={roverBadgeStyle(message.roverColor, 0.14)}
|
||||||
>
|
>
|
||||||
{message.roverId}
|
{message.roverId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
{toolsToggle}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatRowClass(message) {
|
function chatRowClass(message) {
|
||||||
if (isBotSystemMessage(message)) {
|
if (isBotMessage(message)) {
|
||||||
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
return 'surface-muted flex flex-col gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
||||||
}
|
}
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||||
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
return `surface-muted flex flex-col gap-0.5 text-sm ${
|
||||||
isAdmin
|
isAdmin
|
||||||
? 'border border-amber-400/30'
|
? 'border border-amber-400/30'
|
||||||
: message.fromDiscord
|
: message.fromDiscord
|
||||||
@@ -119,18 +135,59 @@ function chatRowClass(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatMessageRow({ message }) {
|
export default function ChatMessageRow({ message }) {
|
||||||
const isBot = isBotSystemMessage(message);
|
const isBot = isBotMessage(message);
|
||||||
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
|
const isSpectator = role === 'spectator';
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isOpen = isSpectator || open;
|
||||||
|
const toolCalls = Array.isArray(message?.toolCalls) ? message.toolCalls : [];
|
||||||
|
const hasText = Boolean(String(message?.text || '').trim());
|
||||||
|
const toolsToggle =
|
||||||
|
toolCalls.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded border border-slate-600/70 bg-slate-800/60 px-1 py-[1px] text-[0.65rem] text-slate-200 hover:bg-slate-700/70"
|
||||||
|
onClick={() => {
|
||||||
|
if (isSpectator) return;
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? '▼' : '▶'} Tools ({toolCalls.length})
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
return (
|
return (
|
||||||
<div className={chatRowClass(message)}>
|
<div className={chatRowClass(message)}>
|
||||||
<ChatIdentity message={message} />
|
<div className="flex w-full items-start gap-0.5">
|
||||||
<span
|
<span
|
||||||
className={`break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
className={`min-w-0 flex-1 break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||||
>
|
>
|
||||||
{message.text}
|
<ChatIdentity message={message} toolsToggle={toolsToggle} />
|
||||||
</span>
|
{!isOpen && hasText ? ` ${message.text}` : ''}
|
||||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
</span>
|
||||||
{formatTime(message.ts)}
|
<span className="shrink-0 text-[0.65rem] text-slate-400/60">
|
||||||
</span>
|
{formatTime(message.ts)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen && toolCalls.length > 0 ? (
|
||||||
|
<div className="w-full rounded border border-slate-700/70 bg-slate-900/70 p-0.5 text-[0.68rem] text-slate-200">
|
||||||
|
{toolCalls.map((entry, idx) => {
|
||||||
|
const status = String(entry?.status || 'unknown');
|
||||||
|
const statusLabel = status === 'ok' ? 'ok' : status === 'blocked' ? 'blocked' : status === 'error' ? 'error' : 'unknown';
|
||||||
|
const argsText = JSON.stringify(entry?.args && typeof entry.args === 'object' ? entry.args : {});
|
||||||
|
return (
|
||||||
|
<div key={`${entry?.tool || 'tool'}-${idx}`} className="mb-0.5 last:mb-0">
|
||||||
|
<div>{statusLabel} {entry?.tool || 'unknown'} args={argsText}</div>
|
||||||
|
{entry?.error ? <div className="text-rose-300">error: {String(entry.error)}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isOpen && hasText ? (
|
||||||
|
<div className={`w-full break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||||
import ChatTypingRow from '../ChatTypingRow/index.jsx';
|
import ChatTypingRow from '../ChatTypingRow/index.jsx';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
|
|
||||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||||
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
|
||||||
@@ -16,6 +18,8 @@ export default function ChatPanel({
|
|||||||
hideSpectatorNotice = false,
|
hideSpectatorNotice = false,
|
||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
allowSpectatorInput = false,
|
allowSpectatorInput = false,
|
||||||
|
title = 'Chat and TTS',
|
||||||
|
nicknameLayout = 'inline',
|
||||||
}) {
|
}) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
@@ -103,9 +107,16 @@ export default function ChatPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
|
||||||
|
const isStackedNickname = nicknameLayout === 'stacked';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel-section space-y-0.5 text-base ${fillHeight ? 'flex h-full flex-col overflow-hidden' : ''}`}>
|
<CardFrame
|
||||||
|
title={title}
|
||||||
|
|
||||||
|
hideHeader={!title}
|
||||||
|
fillHeight={fillHeight}
|
||||||
|
bodyClassName="space-y-0.5 text-base"
|
||||||
|
>
|
||||||
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
<div className={`surface overflow-y-auto space-y-0.5 px-0 ${listClass}`} ref={listRef}>
|
||||||
{sorted.length === 0 && typingRows.length === 0 ? (
|
{sorted.length === 0 && typingRows.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||||
@@ -117,9 +128,15 @@ export default function ChatPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!hideInput && (
|
{!hideInput && (
|
||||||
<form className="flex flex-wrap items-stretch gap-0.5" onSubmit={handleSend}>
|
<form
|
||||||
|
className={`flex items-stretch gap-0.5 ${isStackedNickname ? 'flex-col' : 'flex-wrap'}`}
|
||||||
|
onSubmit={handleSend}
|
||||||
|
>
|
||||||
|
<div className={isStackedNickname ? 'w-full' : 'w-[9rem] sm:w-[10rem] shrink-0'}>
|
||||||
|
<NicknameForm compact />
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="field-input flex-1 min-w-[10rem]"
|
className={`field-input ${isStackedNickname ? 'w-full min-w-0' : 'flex-1 min-w-[10rem]'}`}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value;
|
const next = e.target.value;
|
||||||
@@ -146,7 +163,11 @@ export default function ChatPanel({
|
|||||||
disabled={!canChat}
|
disabled={!canChat}
|
||||||
/>
|
/>
|
||||||
{ttsSupported && (
|
{ttsSupported && (
|
||||||
<div className="flex flex-wrap items-center gap-0.5 basis-full sm:basis-auto">
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-0.5 ${
|
||||||
|
isStackedNickname ? 'w-full' : 'basis-full sm:basis-auto'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
<label className="flex items-center gap-0.5 text-xs text-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -206,12 +227,12 @@ export default function ChatPanel({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canChat || sending}
|
disabled={!canChat || sending}
|
||||||
className="button-dark h-full disabled:opacity-50 self-stretch"
|
className={`button-dark disabled:opacity-50 ${isStackedNickname ? 'w-full h-8' : 'h-full self-stretch'}`}
|
||||||
>
|
>
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChatIdentity, chatRowClass } from '../ChatMessageRow/index.jsx';
|
|||||||
|
|
||||||
export default function ChatTypingRow({ message }) {
|
export default function ChatTypingRow({ message }) {
|
||||||
return (
|
return (
|
||||||
<div className={`${chatRowClass(message)} italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
|
<div className={`${chatRowClass(message)} !flex-row items-center italic opacity-80 border-slate-600/40 bg-slate-900/40`}>
|
||||||
<ChatIdentity message={message} />
|
<ChatIdentity message={message} />
|
||||||
<span className="text-slate-300">typing...</span>
|
<span className="text-slate-300">typing...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useControlSystem } from '../../controls/index.js';
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
|
|
||||||
export function deriveDriveDockState(frame) {
|
export function deriveDriveDockState(frame) {
|
||||||
const sensors = frame?.sensors || {};
|
const sensors = frame?.sensors || {};
|
||||||
@@ -68,7 +69,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
|||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-1">
|
||||||
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
<div className="surface w-full max-w-md space-y-0.5 border border-indigo-700 bg-indigo-950/90 p-1 text-slate-100 shadow-2xl">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-lg font-semibold text-indigo-50">Dock Rover</p>
|
<p className="text-lg font-semibold text-indigo-50">Manual Docking Assist</p>
|
||||||
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
<p className="text-sm text-slate-200">{instructions.summary}</p>
|
||||||
<StepList steps={instructions.steps} tone="indigo" />
|
<StepList steps={instructions.steps} tone="indigo" />
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +87,7 @@ function DockModal({ instructions, onConfirm, onCancel, pending }) {
|
|||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
|
className="bg-indigo-600 px-0.5 py-1 font-semibold text-indigo-50 transition hover:bg-indigo-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{pending ? 'Docking…' : 'Confirm Dock'}
|
{pending ? 'Starting…' : 'Enter Assist'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,12 +107,14 @@ export default function DriveDockAction({
|
|||||||
state: { roverId, keymap },
|
state: { roverId, keymap },
|
||||||
actions,
|
actions,
|
||||||
} = useControlSystem();
|
} = useControlSystem();
|
||||||
|
const dockAssist = useManualDockAssist();
|
||||||
const frame = useTelemetryFrame(roverId);
|
const frame = useTelemetryFrame(roverId);
|
||||||
const state = driveDockState ?? deriveDriveDockState(frame);
|
const state = driveDockState ?? deriveDriveDockState(frame);
|
||||||
const { driving, docked, charging, dockingInProgress } = state;
|
const { driving, docked, charging, dockingInProgress } = state;
|
||||||
const [pending, setPending] = useState(null);
|
const [pending, setPending] = useState(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const manualAssistActive = Boolean(dockAssist.active);
|
||||||
|
|
||||||
const driveDisabled = !roverId || pending !== null;
|
const driveDisabled = !roverId || pending !== null;
|
||||||
const dockDisabled = !roverId || pending !== null;
|
const dockDisabled = !roverId || pending !== null;
|
||||||
@@ -120,10 +123,13 @@ export default function DriveDockAction({
|
|||||||
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
const dockKeyLabel = formatKeyLabel(keymap?.dockMacro?.[0]);
|
||||||
|
|
||||||
const dockInstructions = {
|
const dockInstructions = {
|
||||||
summary: 'You are about to trigger an automatic docking attempt! To have a successful dock:',
|
summary: 'Use assist mode to manually line up with the dock.',
|
||||||
steps: ['Line up the rover straight in front of the dock', 'Make sure the rover is about one foot or half a meter away from the dock', 'Press Confirm Dock to begin the docking process'],
|
steps: [
|
||||||
|
'Line up the center dot of your rover with the center of the dock',
|
||||||
|
'The UI will indicate when you are successfully docked',
|
||||||
|
'When good contact is made, the dock will stop flashing.',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const dockButtonCaption = 'Click this button to initate auto-dock.';
|
|
||||||
|
|
||||||
const startDriveInstructions = {
|
const startDriveInstructions = {
|
||||||
summary: 'You must enable driving mode before you can move the rover.',
|
summary: 'You must enable driving mode before you can move the rover.',
|
||||||
@@ -131,13 +137,14 @@ export default function DriveDockAction({
|
|||||||
};
|
};
|
||||||
const dockValue = docked ? 'Docked' : 'Undocked';
|
const dockValue = docked ? 'Docked' : 'Undocked';
|
||||||
const dockTone = docked ? 'good' : 'bad';
|
const dockTone = docked ? 'good' : 'bad';
|
||||||
const chargeValue = charging ? 'Charging' : docked ? 'Charging in 5s' : '—';
|
const chargeValue = charging ? 'Charging' : docked ? 'Charging soon...' : '—';
|
||||||
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
const chargeTone = charging ? 'good' : docked ? 'warn' : 'bad';
|
||||||
|
|
||||||
const handleReturnToDrive = async () => {
|
const handleReturnToDrive = async () => {
|
||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
setPending('drive');
|
setPending('drive');
|
||||||
try {
|
try {
|
||||||
|
dockAssist.exitAssist();
|
||||||
actions.setMode('drive');
|
actions.setMode('drive');
|
||||||
await actions.runMacro('drive-sequence');
|
await actions.runMacro('drive-sequence');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -153,6 +160,7 @@ export default function DriveDockAction({
|
|||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setPending('drive');
|
setPending('drive');
|
||||||
try {
|
try {
|
||||||
|
dockAssist.exitAssist();
|
||||||
actions.setMode('drive');
|
actions.setMode('drive');
|
||||||
await actions.runMacro('drive-sequence');
|
await actions.runMacro('drive-sequence');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -166,8 +174,7 @@ export default function DriveDockAction({
|
|||||||
if (!roverId || pending) return;
|
if (!roverId || pending) return;
|
||||||
setPending('dock');
|
setPending('dock');
|
||||||
try {
|
try {
|
||||||
actions.setMode('dock');
|
dockAssist.enterAssist();
|
||||||
await actions.runMacro('seek-dock');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,6 +186,10 @@ export default function DriveDockAction({
|
|||||||
|
|
||||||
const handleOpenDock = () => {
|
const handleOpenDock = () => {
|
||||||
if (dockDisabled) return;
|
if (dockDisabled) return;
|
||||||
|
if (manualAssistActive) {
|
||||||
|
dockAssist.exitAssist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setConfirmOpen(true);
|
setConfirmOpen(true);
|
||||||
};
|
};
|
||||||
@@ -188,7 +199,6 @@ export default function DriveDockAction({
|
|||||||
const ctaText = 'text-center';
|
const ctaText = 'text-center';
|
||||||
const ctaLayout = 'items-center justify-between';
|
const ctaLayout = 'items-center justify-between';
|
||||||
const compactLayout = 'items-center justify-center';
|
const compactLayout = 'items-center justify-center';
|
||||||
const ctaTextAndLayout = `${ctaText} ${ctaLayout}`;
|
|
||||||
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
const ctaSize = isMobile ? 'text-sm font-semibold' : '';
|
||||||
const emeraldCta =
|
const emeraldCta =
|
||||||
'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700 focus-visible:ring-emerald-300';
|
'border-emerald-300/70 bg-emerald-800 text-emerald-50 hover:bg-emerald-700 focus-visible:ring-emerald-300';
|
||||||
@@ -196,6 +206,8 @@ export default function DriveDockAction({
|
|||||||
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
||||||
const indigoCta =
|
const indigoCta =
|
||||||
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
'border-indigo-300/70 bg-indigo-900 text-indigo-50 hover:bg-indigo-800 focus-visible:ring-indigo-300';
|
||||||
|
const cyanCta =
|
||||||
|
'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800 focus-visible:ring-cyan-300';
|
||||||
const orangeCta =
|
const orangeCta =
|
||||||
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
'border-amber-300/70 bg-amber-900 text-amber-50 hover:bg-amber-800 focus-visible:ring-amber-300';
|
||||||
const forceExpanded = dockingInProgress;
|
const forceExpanded = dockingInProgress;
|
||||||
@@ -243,8 +255,8 @@ export default function DriveDockAction({
|
|||||||
|
|
||||||
if (dockingInProgress) {
|
if (dockingInProgress) {
|
||||||
const inProgressCopy = {
|
const inProgressCopy = {
|
||||||
summary: 'If the rover is failing to dock, click to drive and try again.',
|
summary: 'The rover is currently attempting to dock itself due to being idle.',
|
||||||
steps: ['The rover should be slowly wiggling towards the dock', 'If it is obviously not working, press this button to enter driving mode and try again'],
|
steps: ['Click to return to driving mode.'],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -257,7 +269,7 @@ export default function DriveDockAction({
|
|||||||
>
|
>
|
||||||
<div className="space-y-0.5 w-full">
|
<div className="space-y-0.5 w-full">
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<div className="flex w-full flex-col items-center gap-0.25">
|
||||||
<span className="text-base font-semibold text-amber-50 md:text-lg">Docking in Progress</span>
|
<span className="text-base font-semibold text-amber-50 md:text-lg">Attempting to auto-dock...</span>
|
||||||
{/* {!isMobile && expanded ? ( */}
|
{/* {!isMobile && expanded ? ( */}
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
@@ -292,32 +304,31 @@ export default function DriveDockAction({
|
|||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${
|
? `flex w-full ${baseCardClasses} ${compactHeight} ${ctaText} ${layoutClass} ${ctaSize} ${
|
||||||
compactDockedDriving ? orangeCta : indigoCta
|
manualAssistActive ? cyanCta : compactDockedDriving ? orangeCta : indigoCta
|
||||||
}`
|
}`
|
||||||
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${indigoCta}`
|
: `${baseCardClasses} ${filledHeight} ${ctaText} ${ctaLayout} ${ctaSize} ${manualAssistActive ? cyanCta : indigoCta}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-col items-center gap-0.25">
|
<div className="flex w-full flex-col items-center gap-0.25">
|
||||||
<span className="text-base font-semibold text-indigo-50 md:text-lg">
|
<span className="text-base font-semibold text-indigo-50 md:text-lg">
|
||||||
{compactDockedDriving ? 'Docked!' : 'Dock and Charge'}
|
{compactDockedDriving ? 'Docked!' : manualAssistActive ? 'Exit Docking Assist' : 'Dock and charge'}
|
||||||
</span>
|
</span>
|
||||||
{!isMobile && expanded ? (
|
{!isMobile && expanded ? (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
<div className="flex flex-wrap items-center justify-center gap-0.5">
|
||||||
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
{dockKeyLabel ? <KeyPill label={dockKeyLabel} /> : null}
|
||||||
<ActionPill label="Click to begin docking" tone="indigo" />
|
<ActionPill label={manualAssistActive ? 'Click to exit assist' : 'Click to enable assist'} tone="indigo" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && expanded && (
|
{!isMobile && expanded && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-indigo-50/90">{dockButtonCaption}</p>
|
<p className="text-sm text-indigo-50/90">
|
||||||
|
Get near a dock and press this button to enter docking assist mode.
|
||||||
|
</p>
|
||||||
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
<div className="flex w-full flex-1 flex-col gap-0.5 self-stretch">
|
||||||
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
<StatusRow label="Dock" value={dockValue} tone={dockTone} />
|
||||||
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
<StatusRow label="Charge" value={chargeValue} tone={chargeTone} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-indigo-100/80">
|
|
||||||
Line up straight, about 1 foot away, before docking.
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
|
||||||
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
|
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
||||||
|
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
||||||
|
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
|
||||||
|
import ManualDockAssistOverlay from '../HudOverlays/ManualDockAssistOverlay/index.jsx';
|
||||||
|
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
|
||||||
|
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
||||||
|
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||||
|
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
|
||||||
|
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
|
||||||
|
|
||||||
|
export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||||
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const videoMode = useDriverVideoModePolicy(roverId);
|
||||||
|
const {
|
||||||
|
state: { lastControlIntentAt },
|
||||||
|
} = useControlSystem();
|
||||||
|
|
||||||
|
if (!roverId) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||||
|
<p>You are not assigned to a rover.</p>
|
||||||
|
<p className="mt-0">
|
||||||
|
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||||
|
Click here to visit the spectator page.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileHud = layoutFormat !== 'desktop';
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="relative w-full overflow-hidden bg-black aspect-[4/3]">
|
||||||
|
<RoverMediaPlayer roverId={roverId} videoMode={videoMode} />
|
||||||
|
<TurnsOverlay mobileHud={mobileHud} />
|
||||||
|
<RoverDescriptionOverlay
|
||||||
|
variant="default"
|
||||||
|
mobileHud={mobileHud}
|
||||||
|
controlIntentAt={lastControlIntentAt}
|
||||||
|
/>
|
||||||
|
<HudOverlay
|
||||||
|
layoutFormat={layoutFormat}
|
||||||
|
variant="default"
|
||||||
|
mobileHud={mobileHud}
|
||||||
|
labelScale={1}
|
||||||
|
/>
|
||||||
|
<ManualDockAssistOverlay mobileHud={mobileHud} />
|
||||||
|
<HudChatInput compact={mobileHud} />
|
||||||
|
<OvercurrentOverlay compact={mobileHud} />
|
||||||
|
<LowBatteryOverlay compact={mobileHud} />
|
||||||
|
</div>
|
||||||
|
<DriverBottomStrip mobileHud={mobileHud} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
// Driver Video Panel
|
|
||||||
// Purpose: Defines the Driver Video Panel module and the local helpers/components used in this file.
|
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
|
||||||
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
|
|
||||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
|
||||||
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
|
||||||
import { useControlSystem } from '../../controls/index.js';
|
|
||||||
import VideoTile from '../VideoTile/index.jsx';
|
|
||||||
|
|
||||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
|
||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
|
||||||
const {
|
|
||||||
state: { song, lastControlIntentAt },
|
|
||||||
overcurrentLimiter,
|
|
||||||
} = useControlSystem();
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const [turnCueVisible, setTurnCueVisible] = useState(false);
|
|
||||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
|
||||||
const lastTurnRef = useRef({ active: false, roverId: null });
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [mode]);
|
|
||||||
const rosterEntry =
|
|
||||||
roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
|
|
||||||
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
|
|
||||||
const turnInfo = roverId ? turnQueues?.[roverId] : null;
|
|
||||||
const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
|
|
||||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
|
||||||
const nextDriverId = useMemo(() => {
|
|
||||||
const queue = turnInfo?.queue || [];
|
|
||||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
|
||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
|
||||||
if (idx === -1) {
|
|
||||||
return queue[0] || null;
|
|
||||||
}
|
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
|
||||||
}, [turnInfo?.queue, turnInfo?.current]);
|
|
||||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
|
||||||
const deadline = turnInfo?.deadline || null;
|
|
||||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
|
||||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
|
||||||
const isPreSwitchWindow =
|
|
||||||
mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
|
||||||
const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
|
|
||||||
const turnSeconds =
|
|
||||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
|
||||||
const idleSkipSeconds =
|
|
||||||
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
|
|
||||||
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
|
|
||||||
: null;
|
|
||||||
const turnTimerText = isActiveDriver
|
|
||||||
? turnSeconds != null
|
|
||||||
? `${turnSeconds}s left`
|
|
||||||
: null
|
|
||||||
: isNextDriver && turnSeconds != null
|
|
||||||
? `Your turn in ${turnSeconds}s`
|
|
||||||
: null;
|
|
||||||
const entries = roverId
|
|
||||||
? [
|
|
||||||
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
|
|
||||||
...(hasAudio ? [{ type: 'rover', id: `${roverId}-audio`, key: `${roverId}-audio` }] : []),
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
const sources = useVideoRequests(entries);
|
|
||||||
const info = roverId && shouldShowVideo ? sources[roverId] : null;
|
|
||||||
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
|
|
||||||
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
|
|
||||||
enabled: Boolean(roverId),
|
|
||||||
version: mode,
|
|
||||||
});
|
|
||||||
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
|
|
||||||
const frame = useTelemetryFrame(roverId);
|
|
||||||
const batteryRecord =
|
|
||||||
roverId && roster
|
|
||||||
? roster.find((item) => String(item.id) === String(roverId))
|
|
||||||
: null;
|
|
||||||
const batteryConfig = batteryRecord?.battery ?? null;
|
|
||||||
|
|
||||||
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') {
|
|
||||||
setTurnCueVisible(false);
|
|
||||||
setTurnCueStartAt(null);
|
|
||||||
lastTurnRef.current = { active: false, roverId: null };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastTurn = lastTurnRef.current;
|
|
||||||
const becameActive = isActiveDriver && !lastTurn.active;
|
|
||||||
const roverChanged = isActiveDriver && roverId && roverId !== lastTurn.roverId;
|
|
||||||
if (becameActive || roverChanged) {
|
|
||||||
setTurnCueVisible(true);
|
|
||||||
setTurnCueStartAt(Date.now());
|
|
||||||
} else if (!isActiveDriver && lastTurn.active) {
|
|
||||||
setTurnCueVisible(false);
|
|
||||||
setTurnCueStartAt(null);
|
|
||||||
}
|
|
||||||
lastTurnRef.current = { active: isActiveDriver, roverId };
|
|
||||||
}, [isActiveDriver, roverId, mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!turnCueVisible || !turnCueStartAt) return;
|
|
||||||
if (lastControlIntentAt > turnCueStartAt) {
|
|
||||||
setTurnCueVisible(false);
|
|
||||||
}
|
|
||||||
}, [lastControlIntentAt, turnCueStartAt, turnCueVisible]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
{roverId ? (
|
|
||||||
<VideoTile
|
|
||||||
sessionInfo={info}
|
|
||||||
videoMode={shouldShowVideo ? 'whep' : 'snapshot'}
|
|
||||||
snapshotFeed={snapshotFeed}
|
|
||||||
audioSessionInfo={audioInfo}
|
|
||||||
label={roverLabel}
|
|
||||||
roverColor={batteryRecord?.color || null}
|
|
||||||
telemetryFrame={frame}
|
|
||||||
batteryConfig={batteryConfig}
|
|
||||||
layoutFormat={layoutFormat}
|
|
||||||
overcurrentLimiter={overcurrentLimiter}
|
|
||||||
songNote={song?.note}
|
|
||||||
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
|
|
||||||
showTurnCue={turnCueVisible}
|
|
||||||
turnTimerText={turnTimerText}
|
|
||||||
turnSeconds={turnSeconds}
|
|
||||||
isActiveDriver={isActiveDriver}
|
|
||||||
idleSkipSeconds={idleSkipSeconds}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
|
||||||
<p>You are not assigned to a rover.</p>
|
|
||||||
{/* colored button to visit the spectator page */}
|
|
||||||
<p className="mt-0">
|
|
||||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
|
||||||
Click here to visit the spectator page.
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ export default function FloatingFullscreenButton({ side = 'right', onClick }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`fixed bottom-2 ${sideClass} z-30 flex h-10 w-10 items-center justify-center rounded-full border border-cyan-200/50 bg-slate-900/70 text-cyan-100 shadow-lg backdrop-blur-sm transition hover:bg-slate-800/80 active:scale-95`}
|
className={`fixed bottom-2 ${sideClass} z-30 flex h-10 w-10 items-center justify-center rounded-full border border-slate-700 bg-black text-slate-200 transition hover:bg-zinc-900 active:scale-95`}
|
||||||
aria-label="Enter fullscreen"
|
aria-label="Enter fullscreen"
|
||||||
title="Enter fullscreen"
|
title="Enter fullscreen"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
|||||||
const isIOSMode = mode === 'pwa-hint';
|
const isIOSMode = mode === 'pwa-hint';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
|
<div className="fixed inset-0 z-40 flex items-center justify-center p-1 pointer-events-none bg-black/20">
|
||||||
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
|
<div className="pointer-events-auto w-full max-w-sm surface center">
|
||||||
<div className="space-y-0.5 p-4 text-sm text-slate-100">
|
<div className="space-y-0.5 p-1 text-sm text-slate-100">
|
||||||
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
|
<h2 className="text-base font-semibold text-white border-b border-slate-700">Better in fullscreen!</h2>
|
||||||
{isIOSMode ? (
|
{isIOSMode ? (
|
||||||
<p className="text-slate-300">
|
<p className="text-slate-300">
|
||||||
For fullscreen on iOS, open Safari's share menu and pick <strong>Add to Home Screen</strong>. Launching from
|
For fullscreen on iOS, open Safari's share menu and pick <strong>Add to Home Screen</strong>. Launching from
|
||||||
the home screen removes the browser chrome.
|
the home screen then makes it fullscreen.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-slate-300">
|
<p className="text-slate-300">
|
||||||
@@ -21,6 +21,8 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
|
|||||||
via the system back or home gesture.
|
via the system back or home gesture.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs border-t border-b border-slate-700 p-0.5 text-blue-300 text-center">This will only show once just to let you know. There is a fullscreen button in the bottom right for later use.</p>
|
||||||
|
|
||||||
<div className="flex justify-end gap-0.5 pt-1 text-sm">
|
<div className="flex justify-end gap-0.5 pt-1 text-sm">
|
||||||
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
|
||||||
{isIOSMode ? 'Got it' : 'Not now'}
|
{isIOSMode ? 'Got it' : 'Not now'}
|
||||||
|
|||||||
@@ -8,11 +8,40 @@ const MOBILE_DISMISS_MS = 10000;
|
|||||||
const MAX_FONT_PX = 28;
|
const MAX_FONT_PX = 28;
|
||||||
const MIN_FONT_PX = 14;
|
const MIN_FONT_PX = 14;
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const raw = String(hex || '').trim();
|
||||||
|
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||||
|
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
|
||||||
|
const expanded =
|
||||||
|
normalized.length === 3
|
||||||
|
? normalized
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('')
|
||||||
|
: normalized;
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgba(rgb, alpha) {
|
||||||
|
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
||||||
const goalText = useSessionSelector((state) => {
|
const goalText = useSessionSelector((state) => {
|
||||||
const text = state.session?.globalObjective?.text;
|
const text = state.session?.globalObjective?.text;
|
||||||
return text ? String(text).trim() : '';
|
return text ? String(text).trim() : '';
|
||||||
});
|
});
|
||||||
|
const ownRoverColor = useSessionSelector((state) => {
|
||||||
|
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||||
|
if (!roverId) return null;
|
||||||
|
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||||
|
const rover = roster.find((entry) => String(entry?.id) === roverId);
|
||||||
|
return rover?.color || null;
|
||||||
|
});
|
||||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
||||||
@@ -72,7 +101,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
const containerClass = useMemo(
|
const containerClass = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
'panel-section flex w-full items-center justify-center',
|
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
|
||||||
isMobile ? 'rounded-none' : 'rounded',
|
isMobile ? 'rounded-none' : 'rounded',
|
||||||
'px-1 py-1 text-center font-semibold tracking-tight',
|
'px-1 py-1 text-center font-semibold tracking-tight',
|
||||||
className,
|
className,
|
||||||
@@ -81,6 +110,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
.join(' '),
|
.join(' '),
|
||||||
[className, isMobile],
|
[className, isMobile],
|
||||||
);
|
);
|
||||||
|
const accentRgb = hexToRgb(ownRoverColor);
|
||||||
|
const frameStyle = accentRgb
|
||||||
|
? {
|
||||||
|
borderColor: rgba(accentRgb, 0.35),
|
||||||
|
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 58%, ${rgba(accentRgb, 0.18)} 100%)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (!goalText || !visible) return null;
|
if (!goalText || !visible) return null;
|
||||||
|
|
||||||
@@ -94,7 +130,7 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={containerClass}
|
className={containerClass}
|
||||||
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
||||||
{...dismissProps}
|
{...dismissProps}
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useControlSystem } from '../../controls/index.js';
|
import { useControlSystem } from '../../controls/index.js';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
function StatusBadge({ label, tone = 'muted' }) {
|
function StatusBadge({ label, tone = 'muted' }) {
|
||||||
const styles =
|
const styles =
|
||||||
@@ -255,44 +256,41 @@ export default function HomeAssistantControls() {
|
|||||||
|
|
||||||
if (!ha?.enabled) {
|
if (!ha?.enabled) {
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||||
<p className="text-slate-300">Room Controls</p>
|
|
||||||
<p className="text-slate-500">Not configured on the server.</p>
|
<p className="text-slate-500">Not configured on the server.</p>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entities.length === 0) {
|
if (entities.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-sm text-slate-400">
|
<CardFrame title="Room Controls" bodyClassName="space-y-0.5 text-sm text-slate-400">
|
||||||
<p className="text-slate-300">Room Controls</p>
|
|
||||||
<p className="text-slate-500">No lights or switches configured.</p>
|
<p className="text-slate-500">No lights or switches configured.</p>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = Boolean(ha?.connected);
|
const connected = Boolean(ha?.connected);
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-0.5 text-[0.68rem] text-slate-400">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<span>On</span>
|
||||||
|
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<span>Off</span>
|
||||||
|
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
||||||
|
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel-section space-y-0.5 text-base">
|
<CardFrame title="Room Controls" actions={actions} bodyClassName="space-y-0.5 text-base">
|
||||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<p>Room Controls</p>
|
|
||||||
<span className="text-xs text-slate-500">{entities.length}</span>
|
|
||||||
{controlsLocked ? <StatusBadge label={lockState === 'off' ? 'Locked Off' : 'Locked On'} tone="warn" /> : null}
|
|
||||||
<div className="flex items-center gap-0.5 text-xs text-slate-300 background-black">
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<span>On</span>
|
|
||||||
{onKeyLabel ? <KeyPill label={onKeyLabel} /> : null}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-0.5">
|
|
||||||
<span>Off</span>
|
|
||||||
{offKeyLabel ? <KeyPill label={offKeyLabel} /> : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge label={connected ? 'Connected' : 'Offline'} tone={connected ? 'success' : 'warn'} />
|
|
||||||
</header>
|
|
||||||
{controlsLocked ? (
|
{controlsLocked ? (
|
||||||
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
<p className="rounded border border-amber-600/60 bg-amber-900/40 px-1 py-0.5 text-xs text-amber-100">
|
||||||
{lockState === 'off'
|
{lockState === 'off'
|
||||||
@@ -313,11 +311,11 @@ export default function HomeAssistantControls() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyPill({ label }) {
|
function KeyPill({ label }) {
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
return <span className="rounded border border-slate-600 px-1 text-[0.65rem] text-slate-300">{label}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
// Horn Control
|
// Horn Control
|
||||||
// Purpose: Defines the Horn Control module and the local helpers/components used in this file.
|
// Purpose: Defines the Horn Control module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { HORN_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
|
import { HORN_MAX_FREQUENCY } from '../../controls/constants.js';
|
||||||
|
|
||||||
function clampFreq(value) {
|
function clampFreq(value) {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (!Number.isFinite(num)) return 0;
|
if (!Number.isFinite(num)) return 0;
|
||||||
if (num <= 0) return 0;
|
if (num <= 0) return 0;
|
||||||
return Math.min(5000, Math.round(num));
|
return Math.min(HORN_MAX_FREQUENCY, Math.round(num));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HornControl({
|
export default function HornControl({
|
||||||
@@ -31,6 +32,7 @@ export default function HornControl({
|
|||||||
HORN_SETTINGS_DEFAULTS,
|
HORN_SETTINGS_DEFAULTS,
|
||||||
);
|
);
|
||||||
const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
|
const [waveform, setWaveform] = useState(hornSettings?.waveform || HORN_SETTINGS_DEFAULTS.waveform);
|
||||||
|
const settingsRootRef = useRef(null);
|
||||||
const [freqs, setFreqs] = useState(() => {
|
const [freqs, setFreqs] = useState(() => {
|
||||||
const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs;
|
const base = Array.isArray(hornSettings?.freqs) ? hornSettings.freqs : HORN_SETTINGS_DEFAULTS.freqs;
|
||||||
return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f));
|
return [...base, 0, 0, 0, 0].slice(0, 4).map((f) => clampFreq(f));
|
||||||
@@ -99,6 +101,30 @@ export default function HornControl({
|
|||||||
[saveHornSettings],
|
[saveHornSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = settingsRootRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
|
||||||
|
const handleWheel = (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
const input = target.closest('input[data-horn-freq-index]');
|
||||||
|
if (!input) return;
|
||||||
|
const index = Number(input.dataset.hornFreqIndex);
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= freqs.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const direction = event.deltaY < 0 ? 1 : -1;
|
||||||
|
const current = Number(freqs[index]) || 0;
|
||||||
|
updateFreq(index, current + direction * 25);
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('wheel', handleWheel, { capture: true });
|
||||||
|
};
|
||||||
|
}, [freqs, updateFreq]);
|
||||||
|
|
||||||
const handlePointerDown = (event) => {
|
const handlePointerDown = (event) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
const tag = event.target?.tagName?.toLowerCase();
|
const tag = event.target?.tagName?.toLowerCase();
|
||||||
@@ -176,7 +202,7 @@ export default function HornControl({
|
|||||||
</div>
|
</div>
|
||||||
{showSettings ? (
|
{showSettings ? (
|
||||||
compactSettings ? (
|
compactSettings ? (
|
||||||
<div className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
<div ref={settingsRootRef} className="relative z-10 flex flex-col gap-0.5 text-[0.65rem]">
|
||||||
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
<label className="flex items-center justify-between gap-0.5 text-slate-200">
|
||||||
<span className="text-slate-300">Wave</span>
|
<span className="text-slate-300">Wave</span>
|
||||||
<select
|
<select
|
||||||
@@ -195,9 +221,10 @@ export default function HornControl({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={5000}
|
max={HORN_MAX_FREQUENCY}
|
||||||
step={1}
|
step={1}
|
||||||
value={freq}
|
value={freq}
|
||||||
|
data-horn-freq-index={idx}
|
||||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||||
/>
|
/>
|
||||||
@@ -206,7 +233,10 @@ export default function HornControl({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]">
|
<div
|
||||||
|
ref={settingsRootRef}
|
||||||
|
className="relative z-10 grid grid-cols-[minmax(0,1.2fr)_repeat(4,minmax(0,1fr))] items-center gap-0.5 text-[0.65rem]"
|
||||||
|
>
|
||||||
<label className="flex items-center gap-0.5 text-slate-200">
|
<label className="flex items-center gap-0.5 text-slate-200">
|
||||||
<span className="text-slate-300">Wave</span>
|
<span className="text-slate-300">Wave</span>
|
||||||
<select
|
<select
|
||||||
@@ -224,9 +254,10 @@ export default function HornControl({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={5000}
|
max={HORN_MAX_FREQUENCY}
|
||||||
step={1}
|
step={1}
|
||||||
value={freq}
|
value={freq}
|
||||||
|
data-horn-freq-index={idx}
|
||||||
onChange={(event) => updateFreq(idx, event.target.value)}
|
onChange={(event) => updateFreq(idx, event.target.value)}
|
||||||
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
className="w-full rounded border border-slate-700 bg-slate-900 px-1 py-[2px] text-right text-[0.65rem] font-mono text-slate-100"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
|
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||||
|
import LightBumpBars from '../LightBumpBars/index.jsx';
|
||||||
|
import { buildBatteryVisual } from '../../../lib/battery.js';
|
||||||
|
import BatteryBar from '../../BatteryBar/index.jsx';
|
||||||
|
|
||||||
|
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
|
||||||
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
|
const frame = useTelemetryFrame(effectiveRoverId);
|
||||||
|
const sensors = frame?.sensors ?? null;
|
||||||
|
const batteryConfig = useSessionSelector((state) => {
|
||||||
|
if (!effectiveRoverId) return null;
|
||||||
|
const roster = state.session?.roster || [];
|
||||||
|
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
||||||
|
return rover?.battery ?? null;
|
||||||
|
});
|
||||||
|
const batteryVisual = buildBatteryVisual({
|
||||||
|
charge: sensors?.batteryChargeMah ?? null,
|
||||||
|
config: batteryConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<LightBumpBars roverId={effectiveRoverId} />
|
||||||
|
<div className="panel-section space-y-0.5 text-sm">
|
||||||
|
<BatteryBar visual={batteryVisual} compact={mobileHud} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
import { useChat } from '../../context/ChatContext.jsx';
|
import { useChat } from '../../../context/ChatContext.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||||
|
|
||||||
function HudChatInput({ compact = false }) {
|
function HudChatInput({ compact = false }) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import TopDownMap from '../../TopDownMap/index.jsx';
|
||||||
|
|
||||||
|
export default function HudMapOverlay({
|
||||||
|
sensors,
|
||||||
|
show = true,
|
||||||
|
mapPosition = 'top-center',
|
||||||
|
layoutFormat = 'desktop',
|
||||||
|
mobileHud = false,
|
||||||
|
}) {
|
||||||
|
if (!show) return null;
|
||||||
|
const portraitMobile = layoutFormat === 'mobile-portrait';
|
||||||
|
const mapSize = '240px';
|
||||||
|
const mapScale = portraitMobile ? 0.3 : mobileHud ? 0.33 : 0.7;
|
||||||
|
const mapOpacity = mobileHud ? 0.6 : 0.7;
|
||||||
|
const mapStyle = {
|
||||||
|
width: mapSize,
|
||||||
|
height: mapSize,
|
||||||
|
opacity: mapOpacity,
|
||||||
|
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
|
||||||
|
transformOrigin:
|
||||||
|
mapPosition === 'bottom-left'
|
||||||
|
? 'bottom left'
|
||||||
|
: mapPosition === 'top-center'
|
||||||
|
? 'top center'
|
||||||
|
: 'top right',
|
||||||
|
...(mapPosition === 'bottom-left'
|
||||||
|
? { left: '0.25rem', bottom: '0.25rem' }
|
||||||
|
: mapPosition === 'top-center'
|
||||||
|
? { left: '50%', top: '0.25rem' }
|
||||||
|
: { right: '0.25rem', top: '0.25rem' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute rounded" style={mapStyle}>
|
||||||
|
<TopDownMap sensors={sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
|
||||||
|
|
||||||
|
export default function RoverLabelOverlay({
|
||||||
|
variant = 'default',
|
||||||
|
label,
|
||||||
|
roverColor = null,
|
||||||
|
driverLabel = null,
|
||||||
|
mobileHud = false,
|
||||||
|
labelScale = 1,
|
||||||
|
}) {
|
||||||
|
const labelPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
|
||||||
|
const labelTextClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.8rem]';
|
||||||
|
const labelPosClass = 'bottom-0.5';
|
||||||
|
const labelWrapperStyle = {
|
||||||
|
transform: `translateX(-50%) scale(${labelScale})`,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === 'spectator') {
|
||||||
|
return (
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
|
<div className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColor, 0.18)}
|
||||||
|
>
|
||||||
|
{label || 'No rover'}
|
||||||
|
</span>
|
||||||
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
|
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
|
<span>
|
||||||
|
Rover:{' '}
|
||||||
|
<span
|
||||||
|
className="rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColor, 0.18)}
|
||||||
|
>
|
||||||
|
"{label || 'No rover'}"
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export default function SpectatorTelemetryOverlay({ sensors, mobileHud = false }) {
|
||||||
|
const statusPadClass = mobileHud ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
||||||
|
const telemetryPosClass = mobileHud ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
||||||
|
const telemetryTextClass = mobileHud ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
||||||
|
const telemetryEntries = [
|
||||||
|
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||||
|
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
||||||
|
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
||||||
|
['OI', sensors?.oiMode?.label || '--'],
|
||||||
|
];
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const chargingLabel = sensors?.chargingState?.label || '';
|
||||||
|
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
||||||
|
const oiLabel = sensors?.oiMode?.label || 'Unknown';
|
||||||
|
const oiNormalized = oiLabel.toLowerCase();
|
||||||
|
const oiTone =
|
||||||
|
oiNormalized === 'full'
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: oiNormalized === 'safe'
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: oiNormalized === 'passive'
|
||||||
|
? 'bg-slate-700/80 text-slate-100'
|
||||||
|
: 'bg-slate-700/60 text-slate-200';
|
||||||
|
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
|
||||||
|
const chargingTone = charging
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: docked
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: 'bg-slate-700/70 text-slate-200';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${telemetryTextClass} ${statusPadClass}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5 leading-tight">
|
||||||
|
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
||||||
|
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
||||||
|
</div>
|
||||||
|
{telemetryEntries.map(([labelText, value]) => (
|
||||||
|
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
||||||
|
<span className="text-slate-400">{labelText}</span>
|
||||||
|
<span className="font-semibold text-white">{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Hud Overlay
|
||||||
|
// Purpose: Defines the Hud Overlay module and the local helpers/components used in this file.
|
||||||
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
|
import React from 'react';
|
||||||
|
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
|
||||||
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
|
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||||
|
import RoverLabelOverlay from './RoverLabelOverlay.jsx';
|
||||||
|
import SpectatorTelemetryOverlay from './SpectatorTelemetryOverlay.jsx';
|
||||||
|
import HudMapOverlay from './HudMapOverlay.jsx';
|
||||||
|
|
||||||
|
function HudOverlay({
|
||||||
|
roverId = null,
|
||||||
|
sensors,
|
||||||
|
label,
|
||||||
|
roverColor = null,
|
||||||
|
layoutFormat = 'desktop',
|
||||||
|
variant = 'default',
|
||||||
|
driverLabel = null,
|
||||||
|
showTopDown = undefined,
|
||||||
|
mobileHud = false,
|
||||||
|
mapPosition = null,
|
||||||
|
labelScale = 1,
|
||||||
|
}) {
|
||||||
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
|
const frame = useTelemetryFrame(effectiveRoverId);
|
||||||
|
const rosterInfo = useSessionSelector((state) => {
|
||||||
|
if (!effectiveRoverId) return { label: null, roverColor: null };
|
||||||
|
const roster = state.session?.roster || [];
|
||||||
|
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
||||||
|
return {
|
||||||
|
label: rover?.name || null,
|
||||||
|
roverColor: rover?.color || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const derivedDriverLabel = useSessionSelector((state) => {
|
||||||
|
if (!effectiveRoverId || variant !== 'spectator') return null;
|
||||||
|
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
|
||||||
|
const users = state.session?.users || [];
|
||||||
|
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
||||||
|
return match?.nickname || match?.name || null;
|
||||||
|
});
|
||||||
|
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||||
|
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
||||||
|
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
|
||||||
|
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
|
||||||
|
const isMobile = mobileHud;
|
||||||
|
const [showHudMapDesktop] = useHudMapSetting();
|
||||||
|
const resolvedShowTopDown =
|
||||||
|
typeof showTopDown === 'boolean'
|
||||||
|
? showTopDown
|
||||||
|
: variant === 'spectator'
|
||||||
|
? true
|
||||||
|
: isMobile
|
||||||
|
? true
|
||||||
|
: showHudMapDesktop;
|
||||||
|
const resolvedMapPosition =
|
||||||
|
mapPosition || (variant === 'spectator' ? 'top-center' : isMobile ? 'top-right' : 'top-center');
|
||||||
|
|
||||||
|
if (variant === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'spectator') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<SpectatorTelemetryOverlay sensors={resolvedSensors} mobileHud={isMobile} />
|
||||||
|
<RoverLabelOverlay
|
||||||
|
variant="spectator"
|
||||||
|
label={resolvedLabel}
|
||||||
|
roverColor={resolvedRoverColor}
|
||||||
|
driverLabel={resolvedDriverLabel}
|
||||||
|
mobileHud={isMobile}
|
||||||
|
labelScale={labelScale}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HudMapOverlay
|
||||||
|
sensors={resolvedSensors}
|
||||||
|
show={resolvedShowTopDown}
|
||||||
|
mapPosition={resolvedMapPosition}
|
||||||
|
layoutFormat={layoutFormat}
|
||||||
|
mobileHud={isMobile}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<RoverLabelOverlay
|
||||||
|
variant="default"
|
||||||
|
label={resolvedLabel}
|
||||||
|
roverColor={resolvedRoverColor}
|
||||||
|
mobileHud={isMobile}
|
||||||
|
labelScale={labelScale}
|
||||||
|
/>
|
||||||
|
<HudMapOverlay
|
||||||
|
sensors={resolvedSensors}
|
||||||
|
show={resolvedShowTopDown && variant !== 'spectator'}
|
||||||
|
mapPosition={resolvedMapPosition}
|
||||||
|
layoutFormat={layoutFormat}
|
||||||
|
mobileHud={isMobile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(HudOverlay);
|
||||||
+13
-7
@@ -2,15 +2,21 @@
|
|||||||
// Purpose: Defines the Light Bump Bars module and the local helpers/components used in this file.
|
// Purpose: Defines the Light Bump Bars module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
|
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||||
|
|
||||||
function LightBumpBars({ sensors }) {
|
function LightBumpBars({ roverId = null, sensors }) {
|
||||||
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
|
const frame = useTelemetryFrame(effectiveRoverId);
|
||||||
|
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||||
const values = [
|
const values = [
|
||||||
sensors?.lightBumpLeftSignal,
|
resolvedSensors?.lightBumpLeftSignal,
|
||||||
sensors?.lightBumpFrontLeftSignal,
|
resolvedSensors?.lightBumpFrontLeftSignal,
|
||||||
sensors?.lightBumpCenterLeftSignal,
|
resolvedSensors?.lightBumpCenterLeftSignal,
|
||||||
sensors?.lightBumpCenterRightSignal,
|
resolvedSensors?.lightBumpCenterRightSignal,
|
||||||
sensors?.lightBumpFrontRightSignal,
|
resolvedSensors?.lightBumpFrontRightSignal,
|
||||||
sensors?.lightBumpRightSignal,
|
resolvedSensors?.lightBumpRightSignal,
|
||||||
];
|
];
|
||||||
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
||||||
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Low Battery Overlay
|
||||||
|
// Purpose: Defines the Low Battery Overlay module and the local helpers/components used in this file.
|
||||||
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
|
import React from 'react';
|
||||||
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
|
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||||
|
import { buildBatteryVisual } from '../../../lib/battery.js';
|
||||||
|
|
||||||
|
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
|
||||||
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
|
const frame = useTelemetryFrame(effectiveRoverId);
|
||||||
|
const rosterBatteryConfig = useSessionSelector((state) => {
|
||||||
|
if (!effectiveRoverId) return null;
|
||||||
|
const roster = state.session?.roster || [];
|
||||||
|
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
|
||||||
|
return rover?.battery ?? null;
|
||||||
|
});
|
||||||
|
const resolvedSensors = sensors ?? frame?.sensors ?? null;
|
||||||
|
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
|
||||||
|
const battery = buildBatteryVisual({
|
||||||
|
charge: resolvedSensors?.batteryChargeMah ?? null,
|
||||||
|
config: resolvedBatteryConfig,
|
||||||
|
});
|
||||||
|
if (!battery?.available) return null;
|
||||||
|
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||||
|
|
||||||
|
const message = battery.urgentActive
|
||||||
|
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
|
||||||
|
: 'Battery low! please dock and charge the rover soon.';
|
||||||
|
|
||||||
|
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
||||||
|
const textClass = compact ? 'text-sm' : 'text-2xl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
||||||
|
<div>{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(LowBatteryOverlay);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user